You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@roller.apache.org by sn...@apache.org on 2026/10/10 19:41:03 UTC
(roller) branch master updated: Replace ROME Propono AtomPub server with self-contained StAX implementation (#210)
This is an automated email from the ASF dual-hosted git repository.
snoopdave pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/roller.git
The following commit(s) were added to refs/heads/master by this push:
new c2ca40ed8 Replace ROME Propono AtomPub server with self-contained StAX implementation (#210)
c2ca40ed8 is described below
commit c2ca40ed8d5148c705e2d09b0cadfe51ce731da6
Author: David M. Johnson <sn...@users.noreply.github.com>
AuthorDate: Sat Oct 10 15:40:57 2026 -0400
Replace ROME Propono AtomPub server with self-contained StAX implementation (#210)
* 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.
* 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.
* 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.
* 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.
---
CHANGES.md | 10 +
app/pom.xml | 38 ++-
.../webservices/atomprotocol/AtomCategories.java | 53 ++++
.../webservices/atomprotocol/AtomCategory.java | 53 ++++
.../webservices/atomprotocol/AtomCollection.java | 60 ++++
.../webservices/atomprotocol/AtomConstants.java | 42 +++
.../webservices/atomprotocol/AtomContent.java | 54 ++++
.../webservices/atomprotocol/AtomEntry.java | 141 +++++++++
.../webservices/atomprotocol/AtomException.java | 47 +++
.../webservices/atomprotocol/AtomFeed.java | 74 +++++
.../webservices/atomprotocol/AtomLink.java | 60 ++++
.../atomprotocol/AtomMediaResource.java | 63 ++++
.../atomprotocol/AtomNotAuthorizedException.java | 31 ++
.../atomprotocol/AtomNotFoundException.java | 30 ++
.../webservices/atomprotocol/AtomPerson.java | 43 +++
.../webservices/atomprotocol/AtomReader.java | 285 +++++++++++++++++
.../webservices/atomprotocol/AtomRequest.java | 81 +++++
.../webservices/atomprotocol/AtomServiceDoc.java | 33 ++
.../webservices/atomprotocol/AtomWorkspace.java | 42 +++
.../webservices/atomprotocol/AtomWriter.java | 240 ++++++++++++++
.../webservices/atomprotocol/EntryCollection.java | 230 ++++++--------
.../webservices/atomprotocol/MediaCollection.java | 293 +++++++++--------
.../atomprotocol/RollerAtomHandler.java | 84 ++---
.../atomprotocol/RollerAtomHandlerFactory.java | 47 ---
.../atomprotocol/RollerAtomService.java | 76 ++---
.../atomprotocol/RollerAtomServlet.java | 351 ++++++++++++---------
.../webservices/atomprotocol/package-info.java | 3 +-
app/src/main/resources/propono.properties | 18 --
.../webservices/atomprotocol/AtomEntryTest.java | 55 ++++
.../atomprotocol/AtomExceptionTest.java | 59 ++++
.../webservices/atomprotocol/AtomReaderTest.java | 181 +++++++++++
.../webservices/atomprotocol/AtomRequestTest.java | 97 ++++++
.../atomprotocol/AtomSchemaValidationTest.java | 198 ++++++++++++
.../webservices/atomprotocol/AtomWriterTest.java | 257 +++++++++++++++
.../atomprotocol/MediaCollectionPathTest.java | 64 +++-
.../atomprotocol/MediaCollectionTest.java | 1 -
.../atomprotocol/RollerAtomProtocolTest.java | 258 +++++++++++++++
.../atomprotocol/RollerAtomServletTest.java | 248 ++++++++++-----
app/src/test/resources/atompub/app-service.rnc | 192 +++++++++++
app/src/test/resources/atompub/atom.rnc | 288 +++++++++++++++++
40 files changed, 3786 insertions(+), 694 deletions(-)
diff --git a/CHANGES.md b/CHANGES.md
index 68a5944c8..0d8517485 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/pom.xml b/app/pom.xml
index d09ab96ee..e2ddac3ed 100644
--- a/app/pom.xml
+++ b/app/pom.xml
@@ -55,7 +55,7 @@ limitations under the License.
<maven-war.version>3.5.1</maven-war.version>
<maven-surefire.version>3.5.6</maven-surefire.version>
<maven-antrun.version>1.0b3</maven-antrun.version>
- <rome.version>1.19.0</rome.version> <!-- locked in place since next version removes popono -->
+ <rome.version>1.19.0</rome.version> <!-- ROME core; used for feeds/planet (AtomPub no longer uses Propono) -->
<slf4j.version>2.0.19</slf4j.version>
<spring.version>5.3.39</spring.version>
<spring.security.version>5.8.16</spring.security.version>
@@ -529,20 +529,6 @@ limitations under the License.
</exclusions>
</dependency>
- <!-- todo: remove/replace propono -->
- <dependency>
- <groupId>com.rometools</groupId>
- <artifactId>rome-propono</artifactId>
- <version>${rome.version}</version>
- <scope>compile</scope>
- <exclusions>
- <exclusion>
- <groupId>commons-logging</groupId>
- <artifactId>commons-logging</artifactId>
- </exclusion>
- </exclusions>
- </dependency>
-
<dependency>
<groupId>net.oauth.core</groupId>
<artifactId>oauth-provider</artifactId>
@@ -599,6 +585,28 @@ limitations under the License.
<scope>test</scope>
</dependency>
+ <!-- Jing: RELAX NG validator, used to check AtomWriter output against
+ the RFC 4287 / RFC 5023 schemas in AtomSchemaValidationTest. -->
+ <dependency>
+ <groupId>com.thaiopensource</groupId>
+ <artifactId>jing</artifactId>
+ <version>20091111</version>
+ <scope>test</scope>
+ <!-- Jing declares these with runtime scope, which would replace
+ the newer versions that reach the WAR through nekohtml.
+ Jing uses the JDK's XML parser instead. -->
+ <exclusions>
+ <exclusion>
+ <groupId>xerces</groupId>
+ <artifactId>xercesImpl</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>xml-apis</groupId>
+ <artifactId>xml-apis</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
</dependencies>
<build>
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 000000000..02e04c33e
--- /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<AtomCategory> 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<AtomCategory> 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 000000000..3a5995000
--- /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 000000000..2b4507f20
--- /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<String> accepts = new ArrayList<>();
+ private final List<AtomCategories> 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<String> getAccepts() {
+ return accepts;
+ }
+
+ public void setAccepts(List<String> accepts) {
+ this.accepts = accepts;
+ }
+
+ public List<AtomCategories> 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 000000000..0e747f07c
--- /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;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";
+
+ /** 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 000000000..2740f9720
--- /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 000000000..96b000567
--- /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<AtomPerson> authors = new ArrayList<>();
+ private List<AtomCategory> categories = new ArrayList<>();
+ private List<AtomLink> 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<AtomPerson> getAuthors() {
+ return authors;
+ }
+
+ public void setAuthors(List<AtomPerson> authors) {
+ this.authors = authors;
+ }
+
+ public List<AtomCategory> getCategories() {
+ return categories;
+ }
+
+ public void setCategories(List<AtomCategory> categories) {
+ this.categories = categories;
+ }
+
+ public List<AtomLink> getLinks() {
+ return links;
+ }
+
+ public void setLinks(List<AtomLink> 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 000000000..25fb18cfa
--- /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 000000000..d253f100c
--- /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<AtomLink> links = new ArrayList<>();
+ private List<AtomEntry> 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<AtomLink> getLinks() {
+ return links;
+ }
+
+ public void setLinks(List<AtomLink> links) {
+ this.links = links;
+ }
+
+ public List<AtomEntry> getEntries() {
+ return entries;
+ }
+
+ public void setEntries(List<AtomEntry> 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 000000000..2d17d5a91
--- /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 000000000..724ef3079
--- /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/AtomNotAuthorizedException.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotAuthorizedException.java
new file mode 100644
index 000000000..f568c5b13
--- /dev/null
+++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotAuthorizedException.java
@@ -0,0 +1,31 @@
+/*
+* 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 an authenticated user is not permitted to perform the requested
+ * operation (HTTP 401, matching the previous ROME Propono behavior).
+ */
+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 000000000..9f0a3e867
--- /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 000000000..2b713b222
--- /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 000000000..4940d7821
--- /dev/null
+++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java
@@ -0,0 +1,285 @@
+/*
+* 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.servlet.http.HttpServletResponse;
+
+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.
+ *
+ * <p>DTD processing and external entities are disabled, and an entry that
+ * declares a DOCTYPE is refused, to protect against XXE attacks. Parse errors
+ * are reported as HTTP 400.
+ */
+public class AtomReader {
+
+ private static final String XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+ 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. 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();
+ moveToRootElement(r);
+ while (r.nextTag() == XMLStreamConstants.START_ELEMENT) {
+ readEntryChild(r, entry);
+ }
+ return entry;
+ } catch (XMLStreamException ex) {
+ throw new AtomException("Error parsing Atom entry",
+ HttpServletResponse.SC_BAD_REQUEST, ex);
+ } finally {
+ if (r != null) {
+ try {
+ r.close();
+ } catch (XMLStreamException ignored) {
+ // nothing useful to do on close failure
+ }
+ }
+ }
+ }
+
+ 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();
+ String type = r.getAttributeValue(null, "type");
+ content.setType(type);
+ String src = r.getAttributeValue(null, "src");
+ content.setSrc(src);
+ 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 <br/>}, never {@code <br></br>}.
+ */
+ 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("</").append(r.getLocalName()).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"));
+ 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 000000000..a0651fedc
--- /dev/null
+++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java
@@ -0,0 +1,81 @@
+/*
+* Licensed to the Apache Software Foundation (ASF) under one or more
+* contributor license agreements. The ASF licenses this file to You
+* under the Apache License, Version 2.0 (the "License"); you may not
+* use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+* http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License. For additional information regarding
+* copyright in this work, please see the NOTICE file in the top level
+* directory of this distribution.
+*/
+package org.apache.roller.weblogger.webservices.atomprotocol;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * Lightweight wrapper around an {@link HttpServletRequest} for AtomPub handlers.
+ * 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 {
+
+ private static final byte[] EMPTY = new byte[0];
+
+ 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;
+ 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). */
+ 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, 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() {
+ 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 000000000..4640ac792
--- /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<AtomWorkspace> workspaces = new ArrayList<>();
+
+ public List<AtomWorkspace> 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 000000000..9ea26e9af
--- /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<AtomCollection> collections = new ArrayList<>();
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public List<AtomCollection> 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 000000000..2c4c7eab1
--- /dev/null
+++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java
@@ -0,0 +1,240 @@
+/*
+* 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.
+ *
+ * <p>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 {
+ 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 {
+ 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()));
+ for (AtomLink link : feed.getLinks()) {
+ writeLink(w, link);
+ }
+ for (AtomEntry entry : feed.getEntries()) {
+ w.writeStartElement(ATOM_NS, "entry");
+ writeEntryBody(w, entry);
+ w.writeEndElement();
+ }
+ });
+ }
+
+ public void writeServiceDoc(OutputStream out, AtomServiceDoc service) throws AtomException {
+ 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()) {
+ 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();
+ } catch (XMLStreamException 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());
+ 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 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();
+ }
+
+ 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 e637ffc7d..d65daaa82 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 class EntryCollection {
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 class EntryCollection {
}
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 class EntryCollection {
}
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 class EntryCollection {
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 class EntryCollection {
} 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 class EntryCollection {
wesc.setOffset(start);
wesc.setMaxResults(max + 1);
List<WeblogEntry> 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<AtomLink> 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<Entry> atomEntries = new ArrayList<>();
+ List<AtomEntry> 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<Link> 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 class EntryCollection {
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 class EntryCollection {
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 class EntryCollection {
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 class EntryCollection {
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<Content> 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<Category> categories = new ArrayList<>();
- Category atomCat = new Category();
+ List<AtomCategory> 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<WeblogEntryTag> 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<AtomLink> links = new ArrayList<>();
+ AtomLink altlink = new AtomLink();
altlink.setRel("alternate");
altlink.setHref(entry.getPermalink());
- List<Link> 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<Link> otherlinks = new ArrayList<>();
- otherlinks.add(editlink);
- atomEntry.setOtherLinks(otherlinks);
-
- List<Module> 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 @@ public class EntryCollection {
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<Category> categories = entry.getCategories();
+ List<AtomCategory> 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 @@ public class EntryCollection {
// 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 1081b6544..7626ff701 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.IOException;
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;
@@ -74,12 +62,12 @@ public class MediaCollection {
private User user;
private HttpServletResponse response;
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, atomURL, null);
}
@@ -89,10 +77,10 @@ public class MediaCollection {
this.atomURL = atomURL;
this.response = response;
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(),"/");
@@ -100,11 +88,11 @@ public class MediaCollection {
// 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];
@@ -126,7 +114,7 @@ public class MediaCollection {
FileOutputStream fos = new FileOutputStream(tempFile);
Utilities.copyInputToOutput(is, fos);
fos.close();
-
+
// Parse pathinfo to determine file path
String path = filePathFromPathInfo(pathInfo);
String justPath = path;
@@ -171,20 +159,17 @@ public class MediaCollection {
}
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);
@@ -195,16 +180,16 @@ public class MediaCollection {
}
}
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(), "/");
@@ -212,6 +197,12 @@ public class MediaCollection {
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);
@@ -220,14 +211,14 @@ public class MediaCollection {
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(),"/");
@@ -240,25 +231,30 @@ public class MediaCollection {
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);
+ 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);
}
}
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 {
@@ -284,16 +280,17 @@ public class MediaCollection {
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<AtomLink> 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;
@@ -325,7 +322,7 @@ public class MediaCollection {
}
}
});
-
+
if (files != null && start < files.size()) {
for (MediaFile mf : files) {
sortedSet.add(mf);
@@ -333,9 +330,9 @@ public class MediaCollection {
int count = 0;
MediaFile[] sortedResources =
sortedSet.toArray(MediaFile[]::new);
- List<Entry> atomEntries = new ArrayList<>();
+ List<AtomEntry> 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
@@ -344,28 +341,26 @@ public class MediaCollection {
count++;
}
- List<Link> 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);
@@ -373,23 +368,22 @@ public class MediaCollection {
} 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];
@@ -402,18 +396,22 @@ public class MediaCollection {
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();
@@ -432,10 +430,12 @@ public class MediaCollection {
roller.flush();
fis.close();
-
+
log.debug("Exiting");
return;
+ } catch (AtomException ae) {
+ throw ae;
} catch (FileIOException fie) {
throw new AtomException(
"File upload disabled, over-quota or other error", fie);
@@ -449,15 +449,13 @@ public class MediaCollection {
}
}
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(), "/");
@@ -468,30 +466,36 @@ public class MediaCollection {
}
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);
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) {
@@ -511,88 +515,79 @@ public class MediaCollection {
static AtomMediaResource createMediaResource(MediaFile mediaFile,
HttpServletResponse response) throws IOException {
- AtomMediaResource resource = new AtomMediaResource(
- mediaFile.getName(), mediaFile.getLength(),
- new Date(mediaFile.getLastModified()), mediaFile.getInputStream());
- resource.setContentType(mediaFile.getContentType());
+ String contentType = mediaFile.getContentType();
if (response != null) {
MediaTypePolicy.applyResponseHeaders(response,
mediaFile.getContentType(), mediaFile.getName());
- resource.setContentType(
- MediaTypePolicy.responseTypeFor(mediaFile.getContentType()));
+ contentType = MediaTypePolicy.responseTypeFor(mediaFile.getContentType());
}
- return resource;
+ return new AtomMediaResource(mediaFile.getName(), mediaFile.getLength(),
+ contentType, new Date(mediaFile.getLastModified()),
+ mediaFile.getInputStream());
}
-
- 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<AtomLink> links = new ArrayList<>();
+ AtomLink altlink = new AtomLink();
altlink.setRel("alternate");
altlink.setHref(file.getPermalink());
- List<Link> altlinks = new ArrayList<>();
- altlinks.add(altlink);
- entry.setAlternateLinks(altlinks);
-
- List<Link> 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<Content> contents = new ArrayList<>();
- contents.add(content);
- entry.setContents(contents);
-
- List<com.rometools.rome.feed.module.Module> 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"
@@ -607,23 +602,23 @@ public class MediaCollection {
* 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);
@@ -639,15 +634,15 @@ public class MediaCollection {
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 52c8336c8..2844bd1ef 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,42 +16,36 @@
* 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 java.util.Locale;
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 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 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
@@ -90,7 +84,7 @@ import org.apache.roller.weblogger.ui.core.RollerContext;
*
* @author David M Johnson
*/
-public class RollerAtomHandler implements AtomHandler {
+public class RollerAtomHandler {
protected Weblogger roller = null;
protected User user = null;
protected int maxEntries = 20;
@@ -145,10 +139,14 @@ public class RollerAtomHandler implements AtomHandler {
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.
*/
- @Override
public String getAuthenticatedUsername() {
String ret = null;
if (this.user != null) {
@@ -163,10 +161,9 @@ public class RollerAtomHandler implements AtomHandler {
* 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);
@@ -178,8 +175,7 @@ public class RollerAtomHandler implements AtomHandler {
/**
* 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);
}
@@ -187,12 +183,8 @@ public class RollerAtomHandler implements AtomHandler {
/**
* 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);
@@ -211,8 +203,7 @@ public class RollerAtomHandler implements AtomHandler {
* /<blog-name>/resources/offset
* </pre>
*/
- @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")) {
@@ -227,17 +218,10 @@ public class RollerAtomHandler implements AtomHandler {
}
- @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
@@ -256,7 +240,6 @@ public class RollerAtomHandler implements AtomHandler {
/**
* Expects pathInfo of form /blog-name/resource/path/name
*/
- @Override
public AtomMediaResource getMediaResource(AtomRequest areq) throws AtomException {
MediaCollection mcol = new MediaCollection(user, atomURL, response);
return mcol.getMediaResource(areq);
@@ -268,8 +251,7 @@ public class RollerAtomHandler implements AtomHandler {
/**
* 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);
}
@@ -279,7 +261,6 @@ public class RollerAtomHandler implements AtomHandler {
* 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);
@@ -291,7 +272,6 @@ public class RollerAtomHandler implements AtomHandler {
/**
* 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(),"/");
@@ -316,7 +296,6 @@ public class RollerAtomHandler implements AtomHandler {
/**
* True if URL is the introspection URI.
*/
- @Override
public boolean isAtomServiceURI(AtomRequest areq) {
String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/");
return pathInfo.length == 0;
@@ -325,7 +304,6 @@ public class RollerAtomHandler implements AtomHandler {
/**
* True if URL is a entry URI.
*/
- @Override
public boolean isEntryURI(AtomRequest areq) {
return isEntryPath(areq.getPathInfo());
}
@@ -351,7 +329,6 @@ public class RollerAtomHandler implements AtomHandler {
/**
* 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")) {
@@ -363,7 +340,6 @@ public class RollerAtomHandler implements AtomHandler {
/**
* 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")) {
@@ -378,11 +354,6 @@ public class RollerAtomHandler implements AtomHandler {
return false;
}
- @Override
- public boolean isCategoriesURI(AtomRequest arg0) {
- return false;
- }
-
//------------------------------------------------------------------ permissions
@@ -447,8 +418,7 @@ public class RollerAtomHandler implements AtomHandler {
User inUser = roller.getUserManager().getUserByUserName(userID);
if (inUser.getEnabled()) {
String password = userPass.substring(p+1);
- valid = RollerContext.getPasswordEncoder()
- .matches(password, inUser.getPassword());
+ valid = RollerContext.getPasswordEncoder().matches(password, inUser.getPassword());
}
}
}
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/RollerAtomHandlerFactory.java
deleted file mode 100644
index 29d5bb06b..000000000
--- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
-* 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 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}.
- */
-public class RollerAtomHandlerFactory extends AtomHandlerFactory {
-
- /**
- * Return the handler that {@link RollerAtomServlet} already authenticated
- * for this request, or create a new AtomHandler.
- */
- @Override
- public AtomHandler newAtomHandler(
- HttpServletRequest req, HttpServletResponse res) {
- Object handler = req.getAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE);
- if (handler instanceof AtomHandler) {
- req.removeAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE);
- return (AtomHandler) handler;
- }
- return new RollerAtomHandler(req, res);
- }
-}
-
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 60342c63c..74094efba 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.WeblogCategory;
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<WeblogPermission> 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 class RollerAtomService extends AtomService {
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<WeblogCategory> 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 class RollerAtomService extends AtomService {
MediaFileManager mgr = roller.getMediaFileManager();
List<MediaFileDirectory> 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 class RollerAtomService extends AtomService {
}
}
}
-
+
+ /**
+ * 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<String> getAcceptedContentTypeRange() throws WebloggerException {
List<String> accepts = new ArrayList<>();
Weblogger roller = WebloggerFactory.getWeblogger();
- Map<String, RuntimeConfigProperty> config = roller.getPropertiesManager().getProperties();
+ Map<String, RuntimeConfigProperty> 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 @@ public class RollerAtomService extends AtomService {
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
index 909a74ec3..4c765ec98 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
@@ -1,60 +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.
- */
-
+* 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.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
-import java.io.InputStreamReader;
-import java.nio.charset.StandardCharsets;
-import javax.xml.parsers.ParserConfigurationException;
-import javax.servlet.ReadListener;
-import javax.servlet.ServletException;
-import javax.servlet.ServletInputStream;
+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;
-import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
-import com.rometools.propono.atom.server.AtomHandler;
-import com.rometools.propono.atom.server.AtomServlet;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
-import org.apache.roller.weblogger.util.SecureXmlParsers;
-import org.xml.sax.InputSource;
-import org.xml.sax.SAXException;
-import org.xml.sax.SAXParseException;
-import org.xml.sax.XMLReader;
-import org.xml.sax.ext.DefaultHandler2;
-import org.xml.sax.helpers.DefaultHandler;
/**
- * Roller's AtomPub endpoint. It answers only while
- * <code>webservices.enableAtomPub</code> is on, and it reads each Atom entry
- * body with Roller's shared XML parser settings before the Propono servlet
- * handles the request.
+ * 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.
+ *
+ * <p>It answers only while <code>webservices.enableAtomPub</code> is on. Atom
+ * entry bodies are capped at <code>webservices.atomPubMaxEntrySize</code> bytes
+ * and are read only after the request is authenticated.
*/
-public class RollerAtomServlet extends AtomServlet {
+public class RollerAtomServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
- private static final Log LOG = LogFactory.getLog(RollerAtomServlet.class);
+ private static final Log log =
+ LogFactory.getFactory().getInstance(RollerAtomServlet.class);
/** Default maximum Atom entry body size, in bytes. Media uploads are not affected. */
static final int DEFAULT_MAX_ENTRY_BYTES = 1024 * 1024;
@@ -63,102 +57,134 @@ public class RollerAtomServlet extends AtomServlet {
private static final String ATOM_CONTENT_TYPE = "application/atom+xml";
- /**
- * Request attribute that carries the handler authenticated by this servlet
- * to {@link RollerAtomHandlerFactory}, so Propono does not authenticate the
- * request a second time.
- */
- static final String HANDLER_ATTRIBUTE = RollerAtomServlet.class.getName() + ".handler";
-
@Override
- protected void service(HttpServletRequest req, HttpServletResponse res)
- throws ServletException, IOException {
+ protected void service(HttpServletRequest request, HttpServletResponse response)
+ throws IOException {
if (!WebloggerRuntimeConfig.getBooleanProperty("webservices.enableAtomPub")) {
- LOG.debug("AtomPub service is disabled; rejecting request");
- sendText(res, HttpServletResponse.SC_NOT_FOUND, "AtomPub service is disabled");
+ log.debug("AtomPub service is disabled; rejecting request");
+ sendText(response, HttpServletResponse.SC_NOT_FOUND, "AtomPub service is disabled");
return;
}
- if (!carriesEntry(req)) {
- forward(req, res);
+ String method = request.getMethod();
+ if (!"GET".equals(method) && !"POST".equals(method)
+ && !"PUT".equals(method) && !"DELETE".equals(method)) {
+ response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
}
- // Authenticate before reading the body, as Propono does.
- AtomHandler handler = createHandler(req, res);
- if (handler.getAuthenticatedUsername() == null) {
- res.setHeader("WWW-Authenticate", "BASIC realm=\"AtomPub\"");
- res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ // Authenticate before reading the body.
+ RollerAtomHandler handler = createHandler(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);
+ }
return;
}
- req.setAttribute(HANDLER_ATTRIBUTE, handler);
- int maxEntryBytes = maxEntryBytes();
- // Read one byte past the limit, so an oversized body can be detected.
- byte[] body = req.getInputStream().readNBytes(maxEntryBytes + 1);
- if (body.length > maxEntryBytes) {
- sendText(res, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Entry is too large");
+ if ("POST".equals(method) && request.getContentType() == null) {
+ sendText(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE,
+ "No content-type specified in request");
return;
}
- DefaultHandler contentHandler = new DefaultHandler() {
- @Override
- public void error(SAXParseException e) throws SAXException {
- throw e;
+
+ AtomRequest areq;
+ AtomEntry entry = null;
+ if (carriesEntry(request)) {
+ int maxEntryBytes = maxEntryBytes();
+ // Read one byte past the limit, so an oversized body can be detected.
+ byte[] body = request.getInputStream().readNBytes(maxEntryBytes + 1);
+ if (body.length > maxEntryBytes) {
+ sendText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE,
+ "Entry is too large");
+ return;
+ }
+ try {
+ entry = new AtomReader().parseEntry(new ByteArrayInputStream(body));
+ } catch (AtomException e) {
+ log.debug("Rejecting Atom entry that could not be parsed", e);
+ sendText(response, HttpServletResponse.SC_BAD_REQUEST, "Invalid Atom entry");
+ return;
}
- };
- XMLReader reader;
+ 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);
+ }
+
try {
- reader = SecureXmlParsers.newSAXParserFactory().newSAXParser().getXMLReader();
- // Hardening: DOCTYPE declarations should be rejected,
- // regardless whether the secure reader already does it.
- DefaultHandler2 doctypeRefuser = new DefaultHandler2() {
- @Override
- public void startDTD(String name, String publicId, String systemId)
- throws SAXException {
- throw new SAXException("DOCTYPE is not allowed in an Atom entry");
+ switch (method) {
+ case "GET":
+ doGet(handler, areq, response);
+ break;
+ case "POST":
+ doPost(handler, areq, entry, response);
+ break;
+ case "PUT":
+ doPut(handler, areq, entry, response);
+ break;
+ default:
+ handler.deleteEntry(areq);
+ response.setStatus(HttpServletResponse.SC_OK);
+ }
+ } catch (AtomException ae) {
+ if (!response.isCommitted()) {
+ 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());
}
- };
- reader.setProperty("http://xml.org/sax/properties/lexical-handler", doctypeRefuser);
- } catch (ParserConfigurationException | SAXException e) {
- throw new ServletException("Could not create an Atom entry parser", e);
+ }
+ } catch (Exception e) {
+ log.error("Unexpected error handling AtomPub request", e);
+ if (!response.isCommitted()) {
+ 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;
}
- reader.setContentHandler(contentHandler);
- reader.setErrorHandler(contentHandler);
try {
- // Propono reads the entry as UTF-8 text, so check the same text.
- reader.parse(new InputSource(new InputStreamReader(
- new ByteArrayInputStream(body), StandardCharsets.UTF_8)));
- } catch (SAXException e) {
- LOG.debug("Rejecting Atom entry that could not be parsed", e);
- sendText(res, HttpServletResponse.SC_BAD_REQUEST, "Invalid Atom entry");
- return;
+ return new URI(href).toASCIIString();
+ } catch (URISyntaxException e) {
+ return null;
}
- forward(new BufferedBodyRequest(req, body), res);
}
/** Creates the handler that authenticates the request. */
- protected AtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) {
+ protected RollerAtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) {
return new RollerAtomHandler(req, res);
}
- /** Hands the request to the Propono servlet. */
- protected void forward(HttpServletRequest req, HttpServletResponse res)
- throws ServletException, IOException {
- super.service(req, res);
- }
-
/**
- * True when Propono would parse the request body as an Atom entry: a POST
- * of Atom content, or a PUT to an entry URI.
+ * True when the request body is an Atom entry: a POST of Atom content, or
+ * a PUT to an entry URI.
*/
static boolean carriesEntry(HttpServletRequest req) {
String method = req.getMethod();
- if ("POST".equalsIgnoreCase(method)) {
+ if ("POST".equals(method)) {
String contentType = req.getContentType();
return contentType != null && contentType.startsWith(ATOM_CONTENT_TYPE);
}
- if ("PUT".equalsIgnoreCase(method)) {
+ if ("PUT".equals(method)) {
return RollerAtomHandler.isEntryPath(req.getPathInfo());
}
return false;
@@ -176,7 +202,7 @@ public class RollerAtomServlet extends AtomServlet {
} catch (NumberFormatException e) {
// Fall back to the default below.
}
- LOG.warn("Invalid " + MAX_ENTRY_SIZE_PROPERTY + "; using the default entry limit");
+ log.warn("Invalid " + MAX_ENTRY_SIZE_PROPERTY + "; using the default entry limit");
}
return DEFAULT_MAX_ENTRY_BYTES;
}
@@ -188,61 +214,94 @@ public class RollerAtomServlet extends AtomServlet {
res.getWriter().write(message);
}
- /** A request whose body has already been read into memory. */
- static final class BufferedBodyRequest extends HttpServletRequestWrapper {
+ private void doGet(RollerAtomHandler handler, AtomRequest areq, HttpServletResponse response)
+ throws AtomException, IOException {
- private final byte[] body;
+ if (handler.isAtomServiceURI(areq)) {
+ AtomServiceDoc service = handler.getAtomService(areq);
+ response.setContentType(AtomConstants.SERVICE_MEDIA_TYPE);
+ new AtomWriter().writeServiceDoc(response.getOutputStream(), service);
- BufferedBodyRequest(HttpServletRequest request, byte[] body) {
- super(request);
- this.body = body;
- }
+ } else if (handler.isCollectionURI(areq)) {
+ AtomFeed feed = handler.getCollection(areq);
+ response.setContentType(AtomConstants.FEED_MEDIA_TYPE);
+ new AtomWriter().writeFeed(response.getOutputStream(), feed);
- @Override
- public ServletInputStream getInputStream() {
- final ByteArrayInputStream in = new ByteArrayInputStream(body);
- return new ServletInputStream() {
- @Override
- public int read() {
- return in.read();
- }
+ } else if (handler.isEntryURI(areq)) {
+ AtomEntry entry = handler.getEntry(areq);
+ response.setContentType(AtomConstants.ENTRY_MEDIA_TYPE);
+ new AtomWriter().writeEntry(response.getOutputStream(), entry);
- @Override
- public int read(byte[] b, int off, int len) {
- return in.read(b, off, len);
- }
+ } 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());
+ }
- @Override
- public boolean isFinished() {
- return in.available() == 0;
- }
+ } else {
+ throw new AtomNotFoundException("Cannot find specified resource");
+ }
+ }
- @Override
- public boolean isReady() {
- return true;
- }
+ private void doPost(RollerAtomHandler handler, AtomRequest areq, AtomEntry entry,
+ HttpServletResponse response) throws AtomException {
- @Override
- public void setReadListener(ReadListener listener) {
- throw new UnsupportedOperationException();
- }
- };
+ if (!handler.isCollectionURI(areq)) {
+ throw new AtomNotFoundException("Cannot POST to specified URI");
}
- @Override
- public BufferedReader getReader() {
- return new BufferedReader(new InputStreamReader(
- new ByteArrayInputStream(body), StandardCharsets.UTF_8));
+ String contentType = areq.getContentType();
+ AtomEntry created;
+ if (entry != null) {
+ 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(handler, response, created);
+ }
+
+ private void doPut(RollerAtomHandler handler, AtomRequest areq, AtomEntry entry,
+ HttpServletResponse response) throws AtomException {
- @Override
- public int getContentLength() {
- return body.length;
+ if (entry != null) {
+ 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");
}
+ }
- @Override
- public long getContentLengthLong() {
- return body.length;
+ 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);
+ try {
+ OutputStream out = response.getOutputStream();
+ new AtomWriter().writeEntry(out, entry);
+ } catch (IOException ioe) {
+ throw new AtomException("Error writing created entry", ioe);
}
}
}
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 412ae3a32..b76e13aac 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 @@
*/
/**
- <p>Atom Publising Protocol (AtomPub) implementation using ROME Propono.</p>
+ <p>Atom Publishing Protocol (AtomPub) implementation built on the JDK StAX XML
+ APIs. It does not depend on ROME or Propono.</p>
<p>End-point is at [context]/roller-services/app</p>
*/
package org.apache.roller.weblogger.webservices.atomprotocol;
\ No newline at end of file
diff --git a/app/src/main/resources/propono.properties b/app/src/main/resources/propono.properties
deleted file mode 100644
index b15f9e470..000000000
--- a/app/src/main/resources/propono.properties
+++ /dev/null
@@ -1,18 +0,0 @@
-# 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.
-
-com.rometools.propono.atom.server.AtomHandlerFactory=\
-org.apache.roller.weblogger.webservices.atomprotocol.RollerAtomHandlerFactory
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 000000000..8956a3ec1
--- /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 000000000..8219cb3a6
--- /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 000000000..e5f4ce6a0
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java
@@ -0,0 +1,181 @@
+/*
+* 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 javax.servlet.http.HttpServletResponse;
+
+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 =
+ "<?xml version='1.0' encoding='UTF-8'?>"
+ + "<entry xmlns='http://www.w3.org/2005/Atom'"
+ + " xmlns:app='http://www.w3.org/2007/app'>"
+ + " <id>urn:entry:1</id>"
+ + " <title>Hello World</title>"
+ + " <published>2026-06-03T12:34:56Z</published>"
+ + " <updated>2026-06-04T01:02:03Z</updated>"
+ + " <summary type='html'>A summary</summary>"
+ + " <content type='html'><b>Body</b> & more</content>"
+ + " <category term='tech' scheme='http://example.com/cats'/>"
+ + " <category term='java'/>"
+ + " <app:control><app:draft>yes</app:draft></app:control>"
+ + "</entry>";
+
+ AtomEntry entry = parse(xml);
+
+ assertEquals("urn:entry:1", entry.getId());
+ assertEquals("Hello World", entry.getTitle());
+ assertNotNull(entry.getContent());
+ assertEquals("html", entry.getContent().getType());
+ assertEquals("<b>Body</b> & 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 =
+ "<entry xmlns='http://www.w3.org/2005/Atom'>"
+ + "<title>No control</title></entry>";
+ assertFalse(parse(xml).isDraft());
+ }
+
+ @Test
+ public void testDraftNoIsNotDraft() throws Exception {
+ String xml =
+ "<entry xmlns='http://www.w3.org/2005/Atom'"
+ + " xmlns:app='http://www.w3.org/2007/app'>"
+ + "<app:control><app:draft>no</app:draft></app:control></entry>";
+ assertFalse(parse(xml).isDraft());
+ }
+
+ @Test
+ public void testContentWithSrcHasNoValue() throws Exception {
+ String xml =
+ "<entry xmlns='http://www.w3.org/2005/Atom'>"
+ + "<content type='image/png' src='http://example.com/a.png'/></entry>";
+ 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 =
+ "<?xml version='1.0'?>"
+ + "<!DOCTYPE entry [ <!ENTITY xxe SYSTEM 'file:///etc/passwd'> ]>"
+ + "<entry xmlns='http://www.w3.org/2005/Atom'><title>&xxe;</title></entry>";
+ assertThrows(AtomException.class, () -> parse(xml));
+ }
+
+ @Test
+ public void testDoctypeIsRefusedAsBadRequest() {
+ String xml = "<?xml version='1.0'?><!DOCTYPE entry>"
+ + "<entry xmlns='http://www.w3.org/2005/Atom'><title>t</title></entry>";
+ AtomException ex = assertThrows(AtomException.class, () -> parse(xml));
+ assertEquals(HttpServletResponse.SC_BAD_REQUEST, ex.getStatus());
+ }
+
+ @Test
+ public void testMalformedEntryIsBadRequest() {
+ AtomException ex = assertThrows(AtomException.class, () -> parse("<entry><title></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 Blog</title><updated>2020-01-01T00:00:00Z</updated></source>"
+ + "<title>Mine</title>"
+ + "</entry>";
+ AtomEntry entry = parse(xml);
+ assertEquals("Mine", entry.getTitle());
+ assertNull(entry.getUpdated());
+ }
+
+ @Test
+ public void testXhtmlContentAndTitle() throws Exception {
+ String xml =
+ "<entry xmlns='http://www.w3.org/2005/Atom'>"
+ + "<title type='xhtml'><div xmlns='http://www.w3.org/1999/xhtml'>A <em>title</em></div></title>"
+ + "<content type='xhtml'>"
+ + " <xhtml:div xmlns:xhtml='http://www.w3.org/1999/xhtml'>"
+ + "<xhtml:p class='x'>One & two<xhtml:br/>three</xhtml:p>"
+ + "</xhtml:div>"
+ + "</content>"
+ + "</entry>";
+ AtomEntry entry = parse(xml);
+ assertEquals("A <em>title</em>", entry.getTitle());
+ assertEquals("xhtml", entry.getContent().getType());
+ assertEquals("<p class=\"x\">One & two<br/>three</p>", entry.getContent().getValue());
+ }
+}
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 000000000..75181fba6
--- /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 000000000..f5d6fdc26
--- /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<String> validate(String schemaResource, byte[] xml) throws Exception {
+ List<String> 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("<b>Body</b> & 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<String> 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<String> 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<String> 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<String> 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 000000000..36870dd8e
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriterTest.java
@@ -0,0 +1,257 @@
+/*
+* 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("<b>Body</b> & 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("<b>Body</b> & 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"));
+ // RFC 5023 10.2: app:edited is a direct child of atom:entry
+ assertEquals(doc.getDocumentElement(), element(doc, APP_NS, "edited").getParentNode());
+ }
+
+ @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("<b>Body</b> & 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/MediaCollectionPathTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java
index dd145fdb4..b8d956263 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
@@ -18,20 +18,14 @@ package org.apache.roller.weblogger.webservices.atomprotocol;
import java.io.ByteArrayInputStream;
import java.util.Collections;
-import java.util.List;
-
-import com.rometools.propono.atom.server.AtomException;
-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 org.apache.roller.weblogger.business.FileContentManager;
import org.apache.roller.weblogger.business.MediaFileManager;
import org.apache.roller.weblogger.business.WeblogManager;
import org.apache.roller.weblogger.business.Weblogger;
import org.apache.roller.weblogger.business.WebloggerFactory;
import org.apache.roller.weblogger.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;
@@ -104,7 +98,7 @@ class MediaCollectionPathTest {
@Test
void namedCollectionIsLookedUpByItsName() throws Exception {
- Feed feed = collection.getCollection(request("/blog/resources/default"));
+ AtomFeed feed = collection.getCollection(request("/blog/resources/default"));
assertTrue(feed.getEntries().isEmpty());
verify(files).getMediaFileDirectoryByName(weblog, "default");
@@ -146,6 +140,50 @@ class MediaCollectionPathTest {
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 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,
+ () -> 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 -> {
@@ -161,12 +199,12 @@ class MediaCollectionPathTest {
return request;
}
- private static Entry mediaEntry(String title) {
- Content body = new Content();
+ private static AtomEntry mediaEntry(String title) {
+ AtomContent body = new AtomContent();
body.setType("image/png");
- Entry entry = new Entry();
+ AtomEntry entry = new AtomEntry();
entry.setTitle(title);
- entry.setContents(List.of(body));
+ entry.setContent(body);
return entry;
}
}
diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionTest.java
index 5e06d78db..868bd85d3 100644
--- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionTest.java
+++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionTest.java
@@ -22,7 +22,6 @@ import java.io.ByteArrayInputStream;
import javax.servlet.http.HttpServletResponse;
-import com.rometools.propono.atom.server.AtomMediaResource;
import org.apache.roller.weblogger.pojos.MediaFile;
import org.apache.roller.weblogger.util.MediaTypePolicy;
import org.junit.jupiter.api.Test;
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 000000000..0e791e43b
--- /dev/null
+++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java
@@ -0,0 +1,258 @@
+/*
+* 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.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.when;
+
+import java.nio.charset.StandardCharsets;
+
+import javax.servlet.ServletContext;
+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.apache.roller.weblogger.ui.core.RollerContext;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+
+/**
+ * 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", "<p>Hello AtomPub</p>", 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("<p>Hello AtomPub</p>", 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("<p>Hello AtomPub</p>", 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)", "<p>Edited body</p>", true));
+ TestUtils.endSession(true);
+
+ wem = WebloggerFactory.getWeblogger().getWeblogEntryManager();
+ WeblogEntry updated = wem.getWeblogEntry(entryId);
+ assertEquals("First Post (edited)", updated.getTitle());
+ assertEquals("<p>Edited body</p>", 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");
+
+ // MediaCollection derives the stored type through the servlet context
+ ServletContext servletContext = mock(ServletContext.class);
+ when(servletContext.getMimeType(anyString())).thenReturn("image/png");
+ AtomEntry created;
+ try (MockedStatic<RollerContext> rollerContext =
+ mockStatic(RollerContext.class, CALLS_REAL_METHODS)) {
+ rollerContext.when(RollerContext::getServletContext).thenReturn(servletContext);
+ 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/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java
index 106fdcfe4..c603b6041 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
@@ -19,22 +19,20 @@
package org.apache.roller.weblogger.webservices.atomprotocol;
import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
-import javax.xml.parsers.ParserConfigurationException;
-import javax.xml.parsers.SAXParserFactory;
import javax.servlet.ReadListener;
-import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
-import com.rometools.propono.atom.server.AtomHandler;
import org.apache.roller.weblogger.config.WebloggerRuntimeConfig;
-import org.apache.roller.weblogger.util.SecureXmlParsers;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -42,14 +40,17 @@ import org.mockito.MockedStatic;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertSame;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.Mockito.mock;
+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;
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;
@@ -72,7 +73,6 @@ class RollerAtomServletTest {
private MockedStatic<WebloggerRuntimeConfig> config;
private RecordingServlet servlet;
private HttpServletResponse response;
- private StringWriter responseBody;
@BeforeEach
void setUp() throws IOException {
@@ -80,9 +80,7 @@ class RollerAtomServletTest {
config.when(() -> WebloggerRuntimeConfig.getBooleanProperty("webservices.enableAtomPub"))
.thenReturn(true);
servlet = new RecordingServlet();
- response = mock(HttpServletResponse.class);
- responseBody = new StringWriter();
- when(response.getWriter()).thenReturn(new PrintWriter(responseBody));
+ response = response();
}
@AfterEach
@@ -100,31 +98,28 @@ class RollerAtomServletTest {
verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND);
verify(request, never()).getInputStream();
- assertNull(servlet.forwarded);
+ assertNull(servlet.handler);
- HttpServletResponse readResponse = mock(HttpServletResponse.class);
- when(readResponse.getWriter()).thenReturn(new PrintWriter(new StringWriter()));
+ HttpServletResponse readResponse = response();
servlet.service(request("GET", "/blog/entries", null, ""), readResponse);
verify(readResponse).setStatus(HttpServletResponse.SC_NOT_FOUND);
- assertNull(servlet.forwarded);
+ assertNull(servlet.handler);
}
@Test
- void readsPassThroughUnchanged() throws Exception {
- HttpServletRequest request = request("GET", "/blog/entries", null, "");
-
- servlet.service(request, response);
+ void readsAreDispatchedToTheHandler() throws Exception {
+ servlet.service(request("GET", "/blog/entries", null, ""), response);
- assertSame(request, servlet.forwarded);
+ verify(servlet.handler).getCollection(any());
}
@Test
- void wellFormedEntryIsForwardedWithTheSameBody() throws Exception {
+ void wellFormedEntryIsPostedWithTheSameBody() throws Exception {
servlet.service(request("POST", "/blog/entries", "application/atom+xml;type=entry", ENTRY), response);
- assertNotNull(servlet.forwarded);
- assertArrayEquals(ENTRY.getBytes(StandardCharsets.UTF_8), servlet.forwardedBody);
+ assertEquals("Hello", servlet.postedEntry.getTitle());
+ assertArrayEquals(ENTRY.getBytes(StandardCharsets.UTF_8), servlet.body);
}
@Test
@@ -132,7 +127,7 @@ class RollerAtomServletTest {
servlet.service(request("POST", "/blog/entries", "application/atom+xml", ENTRY_WITH_DOCTYPE), response);
verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
- assertNull(servlet.forwarded);
+ assertNull(servlet.body);
}
@Test
@@ -140,24 +135,37 @@ class RollerAtomServletTest {
servlet.service(request("PUT", "/blog/entry/abc", "text/plain", ENTRY_WITH_DOCTYPE), response);
verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
- assertNull(servlet.forwarded);
+ assertNull(servlet.body);
}
@Test
void mediaUploadsBypassTheEntryLimit() throws Exception {
- mediaUploadIsForwardedWithoutApplyingTheEntryLimit("POST", "/blog/resources");
- mediaUploadIsForwardedWithoutApplyingTheEntryLimit("PUT", "/blog/resources/image.png");
+ mediaUploadBypassesTheEntryLimit("POST", "/blog/resources");
+ servlet.body = null;
+ mediaUploadBypassesTheEntryLimit("PUT", "/blog/resource/image.png");
}
- private void mediaUploadIsForwardedWithoutApplyingTheEntryLimit(String method, String path) 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, response);
- assertSame(request, servlet.forwarded);
+ 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
@@ -171,7 +179,7 @@ class RollerAtomServletTest {
servlet.service(request, response);
verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
- assertNull(servlet.forwarded);
+ assertNull(servlet.body);
assertEquals(99, input.available(), "Read only one byte past the entry limit");
}
@@ -182,14 +190,13 @@ class RollerAtomServletTest {
servlet.service(request("POST", "/blog/entries", "application/atom+xml", body), response);
- assertNotNull(servlet.forwarded);
- assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.forwardedBody);
+ assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.body);
}
@Test
void configuredLimitAppliesToEntryPostsAndUpdates() throws Exception {
configuredLimitCountsUtf8BytesAndChangesOnTheNextRequest("POST", "/blog/entries");
- servlet.forwarded = null;
+ servlet.body = null;
clearInvocations(response);
configuredLimitCountsUtf8BytesAndChangesOnTheNextRequest("PUT", "/blog/entry/abc");
}
@@ -203,12 +210,11 @@ class RollerAtomServletTest {
servlet.service(request(method, path, "application/atom+xml", body), response);
verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
- assertNull(servlet.forwarded);
+ assertNull(servlet.body);
servlet.service(request(method, path, "application/atom+xml", body), response);
- assertNotNull(servlet.forwarded);
- assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.forwardedBody);
+ assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.body);
}
@Test
@@ -225,9 +231,8 @@ class RollerAtomServletTest {
String body = ENTRY + " ".repeat(
RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES - ENTRY.getBytes(StandardCharsets.UTF_8).length);
servlet.service(request("POST", "/blog/entries", "application/atom+xml", body), response);
- assertNotNull(servlet.forwarded);
- assertEquals(RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES, servlet.forwardedBody.length);
- servlet.forwarded = null;
+ assertEquals(RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES, servlet.body.length);
+ servlet.body = null;
byte[] big = new byte[RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES + 1];
HttpServletRequest request = request("POST", "/blog/entries", "application/atom+xml", "");
when(request.getInputStream()).thenReturn(stream(big));
@@ -235,7 +240,7 @@ class RollerAtomServletTest {
servlet.service(request, response);
verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
- assertNull(servlet.forwarded);
+ assertNull(servlet.body);
}
@Test
@@ -246,7 +251,7 @@ class RollerAtomServletTest {
servlet.service(request("POST", "/blog/entries", "application/atom+xml", body), response);
- assertNotNull(servlet.forwarded);
+ assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.body);
}
@Test
@@ -260,22 +265,7 @@ class RollerAtomServletTest {
servlet.service(request(method, path, "application/atom+xml", "<entry><title></entry>"), response);
verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST);
- assertNull(servlet.forwarded);
- }
-
- @Test
- void parserSetupFailureIsAServerError() throws Exception {
- try (MockedStatic<SecureXmlParsers> parsers = mockStatic(SecureXmlParsers.class)) {
- SAXParserFactory factory = mock(SAXParserFactory.class);
- parsers.when(SecureXmlParsers::newSAXParserFactory).thenReturn(factory);
- when(factory.newSAXParser()).thenThrow(new ParserConfigurationException("Cannot create parser"));
-
- assertThrows(ServletException.class, () -> servlet.service(
- request("POST", "/blog/entries", "application/atom+xml", ENTRY), response));
-
- verify(response, never()).setStatus(HttpServletResponse.SC_BAD_REQUEST);
- assertNull(servlet.forwarded);
- }
+ assertNull(servlet.body);
}
@Test
@@ -293,19 +283,78 @@ class RollerAtomServletTest {
verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
verify(request, never()).getInputStream();
- assertNull(servlet.forwarded);
+ assertNull(servlet.body);
}
@Test
- void authenticatedHandlerIsReusedByTheFactory() throws Exception {
- HttpServletRequest request = request("POST", "/blog/entries", "application/atom+xml", ENTRY);
+ void serverErrorsDoNotExposeTheExceptionMessage() throws Exception {
+ servlet.failure = new IllegalStateException("secret internal detail");
- servlet.service(request, response);
+ 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");
+ }
- verify(request).setAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE, servlet.handler);
- when(request.getAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE)).thenReturn(servlet.handler);
- assertSame(servlet.handler, new RollerAtomHandlerFactory().newAtomHandler(request, response));
- verify(request).removeAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE);
+ @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()));
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ when(response.getOutputStream()).thenReturn(new ServletOutputStream() {
+ @Override
+ public void write(int b) {
+ out.write(b);
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setWriteListener(WriteListener listener) {
+ throw new UnsupportedOperationException();
+ }
+ });
+ return response;
}
private static HttpServletRequest request(String method, String pathInfo,
@@ -353,27 +402,66 @@ class RollerAtomServletTest {
};
}
- /** Records what would have been handed to the Propono servlet. */
+ /** Records the request body and entry that reach the handler. */
private static final class RecordingServlet extends RollerAtomServlet {
private static final long serialVersionUID = 1L;
- HttpServletRequest forwarded;
- byte[] forwardedBody;
String userName = "alice";
- AtomHandler handler;
+ String atomURL;
+ String editHref;
+ RuntimeException failure;
+ RollerAtomHandler handler;
+ AtomEntry postedEntry;
+ byte[] body;
@Override
- protected AtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) {
- handler = mock(AtomHandler.class);
- when(handler.getAuthenticatedUsername()).thenReturn(userName);
+ protected RollerAtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) {
+ handler = mock(RollerAtomHandler.class);
+ try {
+ when(handler.getAuthenticatedUsername()).thenReturn(userName);
+ when(handler.getAtomURL()).thenReturn(atomURL);
+ when(handler.isCollectionURI(any())).thenAnswer(call ->
+ call.<AtomRequest>getArgument(0).getPathInfo().matches("/[^/]+/(entries|resources).*"));
+ when(handler.isEntryURI(any())).thenAnswer(call ->
+ RollerAtomHandler.isEntryPath(call.<AtomRequest>getArgument(0).getPathInfo()));
+ when(handler.isMediaEditURI(any())).thenAnswer(call ->
+ call.<AtomRequest>getArgument(0).getPathInfo().matches("/[^/]+/resource/.*"));
+ 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);
+ 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));
+ return new AtomEntry();
+ });
+ doAnswer(call -> {
+ record(call.getArgument(0));
+ return null;
+ }).when(handler).putEntry(any(), any());
+ doAnswer(call -> {
+ record(call.getArgument(0));
+ return null;
+ }).when(handler).putMedia(any());
+ } catch (AtomException e) {
+ throw new IllegalStateException(e);
+ }
return handler;
}
- @Override
- protected void forward(HttpServletRequest req, HttpServletResponse res) throws IOException {
- forwarded = req;
- if (req instanceof BufferedBodyRequest) {
- forwardedBody = req.getInputStream().readAllBytes();
- }
+ private void record(AtomRequest areq) throws IOException {
+ body = areq.getInputStream().readAllBytes();
}
}
}
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 000000000..05d45008f
--- /dev/null
+++ b/app/src/test/resources/atompub/app-service.rnc
@@ -0,0 +1,192 @@
+# 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
+# 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 000000000..d51024598
--- /dev/null
+++ b/app/src/test/resources/atompub/atom.rnc
@@ -0,0 +1,288 @@
+# 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
+# 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)*
+}