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("&amp;");
+                    break;
+                case '<':
+                    sb.append("&lt;");
+                    break;
+                case '>':
+                    sb.append("&gt;");
+                    break;
+                case '"':
+                    sb.append(attribute ? "&quot;" : "\"");
+                    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 &mdash; 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'>&lt;b&gt;Body&lt;/b&gt; &amp; 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 &amp; 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 &amp; 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)*
+}