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

DTD processing and external entities are disabled to protect against XXE + * attacks. + */ +public class AtomReader { + + private final XMLInputFactory factory; + + public AtomReader() { + factory = XMLInputFactory.newInstance(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE); + } + + static Date parseDate(String text) { + if (text == null || text.isBlank()) { + return null; + } + String trimmed = text.trim(); + try { + return Date.from(OffsetDateTime.parse(trimmed).toInstant()); + } catch (Exception ignored) { + try { + return Date.from(Instant.parse(trimmed)); + } catch (Exception ignored2) { + return null; + } + } + } + + /** + * Parse an atom:entry from the given stream. The author is intentionally not + * read; the server sets the entry's creator from the authenticated user. + */ + public AtomEntry parseEntry(InputStream in) throws AtomException { + XMLStreamReader r = null; + try { + r = factory.createXMLStreamReader(in, "UTF-8"); + AtomEntry entry = new AtomEntry(); + while (r.hasNext()) { + if (r.next() != XMLStreamConstants.START_ELEMENT) { + continue; + } + String ns = r.getNamespaceURI(); + String name = r.getLocalName(); + if (ATOM_NS.equals(ns)) { + switch (name) { + case "id": + entry.setId(r.getElementText()); + break; + case "title": + entry.setTitle(r.getElementText()); + break; + case "summary": + entry.setSummary(readContent(r)); + break; + case "content": + if (entry.getContent() == null) { + entry.setContent(readContent(r)); + } + break; + case "published": + entry.setPublished(parseDate(r.getElementText())); + break; + case "updated": + entry.setUpdated(parseDate(r.getElementText())); + break; + case "category": + entry.getCategories().add(readCategory(r)); + break; + default: + break; + } + } else if (APP_NS.equals(ns) && "draft".equals(name)) { + String value = r.getElementText(); + entry.setDraft(value != null && value.trim().equalsIgnoreCase("yes")); + } + } + return entry; + } catch (XMLStreamException ex) { + throw new AtomException("Error parsing Atom entry", ex); + } finally { + if (r != null) { + try { + r.close(); + } catch (XMLStreamException ignored) { + // nothing useful to do on close failure + } + } + } + } + + private AtomContent readContent(XMLStreamReader r) throws XMLStreamException { + AtomContent content = new AtomContent(); + content.setType(r.getAttributeValue(null, "type")); + String src = r.getAttributeValue(null, "src"); + content.setSrc(src); + if (src == null) { + content.setValue(r.getElementText()); + } + return content; + } + + private AtomCategory readCategory(XMLStreamReader r) { + AtomCategory cat = new AtomCategory(); + cat.setTerm(r.getAttributeValue(null, "term")); + cat.setScheme(r.getAttributeValue(null, "scheme")); + cat.setLabel(r.getAttributeValue(null, "label")); + return cat; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java new file mode 100644 index 0000000000..cd6e620038 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java @@ -0,0 +1,65 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; + +import javax.servlet.http.HttpServletRequest; + +/** + * Lightweight wrapper around an {@link HttpServletRequest} for AtomPub handlers. + * The dispatcher servlet reads any request body once into a byte array and + * passes it here so handlers can read it (and so {@code getPathInfo()} never + * returns null for the service-document URI). + */ +public class AtomRequest { + + private static final byte[] EMPTY = new byte[0]; + + private final HttpServletRequest request; + private final byte[] body; + + public AtomRequest(HttpServletRequest request, byte[] body) { + this.request = request; + this.body = (body != null) ? body : EMPTY; + } + + /** Path info relative to the AtomPub servlet, never null ("" for the service doc). */ + public String getPathInfo() { + String pathInfo = request.getPathInfo(); + return (pathInfo != null) ? pathInfo : ""; + } + + public String getHeader(String name) { + return request.getHeader(name); + } + + public String getContentType() { + return request.getContentType(); + } + + /** A fresh stream over the buffered request body. */ + public InputStream getInputStream() { + return new ByteArrayInputStream(body); + } + + public HttpServletRequest getRequest() { + return request; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java new file mode 100644 index 0000000000..4640ac792a --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java @@ -0,0 +1,33 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.List; + +/** + * An APP service document (app:service). + */ +public class AtomServiceDoc { + + private final List workspaces = new ArrayList<>(); + + public List getWorkspaces() { + return workspaces; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWorkspace.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWorkspace.java new file mode 100644 index 0000000000..9ea26e9afc --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWorkspace.java @@ -0,0 +1,42 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.List; + +/** + * An APP workspace (app:workspace) within a service document. + */ +public class AtomWorkspace { + + private String title; + private final List collections = new ArrayList<>(); + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public List getCollections() { + return collections; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java new file mode 100644 index 0000000000..1366a883a0 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java @@ -0,0 +1,235 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.APP_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ATOM_NS; + +import java.io.OutputStream; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Date; + +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +/** + * Serializes the AtomPub wire model ({@link AtomEntry}, {@link AtomFeed}, + * {@link AtomServiceDoc}) to XML using the JDK StAX API. Replaces the ROME and + * Propono serialization the AtomPub server previously relied on. + * + *

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

Atom Publising Protocol (AtomPub) implementation using ROME Propono.

+

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

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

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

Hello AtomPub

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

Hello AtomPub

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

Hello AtomPub

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

Edited body

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

Edited body

", updated.getText()); + assertEquals(PubStatus.DRAFT, updated.getStatus()); + TestUtils.endSession(true); + + // ---- delete ---- + ecol = new EntryCollection(managedUser(), ATOM_URL); + ecol.deleteEntry(request("/" + HANDLE + "/entry/" + entryId, null, null, null)); + TestUtils.endSession(true); + + wem = WebloggerFactory.getWeblogger().getWeblogEntryManager(); + assertNull(wem.getWeblogEntry(entryId)); + } + + @Test + public void testServiceDocument() throws Exception { + AtomServiceDoc service = new RollerAtomService(managedUser(), ATOM_URL).getServiceDoc(); + + assertFalse(service.getWorkspaces().isEmpty()); + AtomWorkspace workspace = service.getWorkspaces().get(0); + + // entries collection present, pointing at this weblog + AtomCollection entries = workspace.getCollections().stream() + .filter(c -> c.getHref() != null && c.getHref().endsWith("/" + HANDLE + "/entries")) + .findFirst().orElse(null); + assertNotNull(entries, "service doc should expose an entries collection"); + assertTrue(entries.getAccepts().contains("application/atom+xml;type=entry")); + // a fixed categories block (weblog categories) plus a free-form one + assertEquals(2, entries.getCategories().size()); + assertTrue(entries.getCategories().stream().anyMatch(AtomCategories::isFixed)); + + // the document serializes to well-formed XML + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + new AtomWriter().writeServiceDoc(out, service); + assertTrue(out.size() > 0); + } + + @Test + public void testMediaUpload() throws Exception { + byte[] bytes = "fake-png-bytes".getBytes(StandardCharsets.UTF_8); + + // a named upload directory, the way the service document advertises them + MediaFileManager mfm = WebloggerFactory.getWeblogger().getMediaFileManager(); + Weblog weblog = WebloggerFactory.getWeblogger().getWeblogManager().getWeblogByHandle(HANDLE); + mfm.createMediaFileDirectory(weblog, "atomuploads"); + TestUtils.endSession(true); + + // ---- upload ---- + MediaCollection mcol = new MediaCollection(managedUser(), ATOM_URL); + AtomEntry mediaIn = new AtomEntry(); + AtomContent content = new AtomContent(); + content.setType("image/png"); + mediaIn.setContent(content); + mediaIn.setTitle("snapshot"); + + AtomEntry created = mcol.postMedia( + request("/" + HANDLE + "/resources/atomuploads", "image/png", "snapshot", bytes), + mediaIn); + + assertNotNull(created.getLinkHref("edit")); + assertNotNull(created.getLinkHref("edit-media")); + String fileName = created.getTitle(); + TestUtils.endSession(true); + + // ---- it persisted into the named media directory ---- + mfm = WebloggerFactory.getWeblogger().getMediaFileManager(); + weblog = WebloggerFactory.getWeblogger().getWeblogManager().getWeblogByHandle(HANDLE); + MediaFileDirectory dir = mfm.getMediaFileDirectoryByName(weblog, "atomuploads"); + assertNotNull(dir, "named upload directory should exist"); + MediaFile stored = dir.getMediaFiles().stream() + .filter(mf -> mf.getName().equals(fileName)) + .findFirst().orElse(null); + assertNotNull(stored, "uploaded media file should be persisted"); + assertEquals("image/png", stored.getContentType()); + } +} diff --git a/app/src/test/resources/atompub/app-service.rnc b/app/src/test/resources/atompub/app-service.rnc new file mode 100644 index 0000000000..c7320097ec --- /dev/null +++ b/app/src/test/resources/atompub/app-service.rnc @@ -0,0 +1,182 @@ +# RELAX NG Compact Syntax Grammar for the Atom Publishing Protocol service +# document (RFC 5023, Appendix B, the start = appService grammar). Used by +# AtomSchemaValidationTest to verify that the service documents produced by +# AtomWriter conform to RFC 5023. + +namespace app = "http://www.w3.org/2007/app" +namespace atom = "http://www.w3.org/2005/Atom" +namespace xsd = "http://www.w3.org/2001/XMLSchema" +namespace xhtml = "http://www.w3.org/1999/xhtml" +namespace local = "" + +start = appService + +# common:attrs + +atomURI = text + +appCommonAttributes = + attribute xml:base { atomURI }?, + attribute xml:lang { atomLanguageTag }?, + attribute xml:space {"default"|"preserved"}?, + undefinedAttribute* + +atomCommonAttributes = appCommonAttributes + +undefinedAttribute = attribute * - (xml:base | xml:space | xml:lang + | local:*) { text } + +atomLanguageTag = xsd:string { + pattern = "([A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*)?" +} + +atomDateConstruct = + appCommonAttributes, + xsd:dateTime + +# app:service +appService = + element app:service { + appCommonAttributes, + ( appWorkspace+ + & extensionElement* ) + } + +# app:workspace + +appWorkspace = + element app:workspace { + appCommonAttributes, + ( atomTitle + & appCollection* + & extensionSansTitleElement* ) + } + +atomTitle = element atom:title { atomTextConstruct } + +# app:collection + +appCollection = + element app:collection { + appCommonAttributes, + attribute href { atomURI }, + ( atomTitle + & appAccept* + & appCategories* + & extensionSansTitleElement* ) + } + +# app:categories + +atomCategory = + element atom:category { + atomCommonAttributes, + attribute term { text }, + attribute scheme { atomURI }?, + attribute label { text }?, + undefinedContent + } + +appInlineCategories = + element app:categories { + attribute fixed { "yes" | "no" }?, + attribute scheme { atomURI }?, + (atomCategory*, + undefinedContent) + } + +appOutOfLineCategories = + element app:categories { + attribute href { atomURI }, + undefinedContent + } + +appCategories = appInlineCategories | appOutOfLineCategories + +# app:accept + +appAccept = + element app:accept { + appCommonAttributes, + ( text? ) + } + +# Simple Extension + +simpleSansTitleExtensionElement = + element * - (app:*|atom:title) { + text + } + +simpleExtensionElement = + element * - (app:*) { + text + } + +# Structured Extension + +structuredSansTitleExtensionElement = + element * - (app:*|atom:title) { + (attribute * { text }+, + (text|anyElement)*) + | (attribute * { text }*, + (text?, anyElement+, (text|anyElement)*)) + } + +structuredExtensionElement = + element * - (app:*) { + (attribute * { text }+, + (text|anyElement)*) + | (attribute * { text }*, + (text?, anyElement+, (text|anyElement)*)) + } + +# Other Extensibility + +extensionSansTitleElement = + simpleSansTitleExtensionElement|structuredSansTitleExtensionElement + +extensionElement = simpleExtensionElement | + structuredExtensionElement + +undefinedContent = (text|anyForeignElement)* + +# Extensions + +anyElement = + element * { + (attribute * { text } + | text + | anyElement)* + } + +anyForeignElement = + element * - app:* { + (attribute * { text } + | text + | anyElement)* + } + +atomPlainTextConstruct = + atomCommonAttributes, + attribute type { "text" | "html" }?, + text + +atomXHTMLTextConstruct = + atomCommonAttributes, + attribute type { "xhtml" }, + xhtmlDiv + +atomTextConstruct = atomPlainTextConstruct | atomXHTMLTextConstruct + +anyXHTML = element xhtml:* { + (attribute * { text } + | text + | anyXHTML)* +} + +xhtmlDiv = element xhtml:div { + (attribute * { text } + | text + | anyXHTML)* +} diff --git a/app/src/test/resources/atompub/atom.rnc b/app/src/test/resources/atompub/atom.rnc new file mode 100644 index 0000000000..6bd5bfbda2 --- /dev/null +++ b/app/src/test/resources/atompub/atom.rnc @@ -0,0 +1,278 @@ +# RELAX NG Compact Syntax Grammar for the Atom Format Specification (RFC 4287, +# Appendix B). Used by AtomSchemaValidationTest to verify that the entries and +# feeds produced by AtomWriter conform to the Atom Syndication Format. The +# embedded Schematron (s:*) annotations are ignored by Jing's RELAX NG validator. + +namespace atom = "http://www.w3.org/2005/Atom" +namespace xhtml = "http://www.w3.org/1999/xhtml" +namespace s = "http://www.ascc.net/xml/schematron" +namespace local = "" + +start = atomFeed | atomEntry + +atomCommonAttributes = + attribute xml:base { atomUri }?, + attribute xml:lang { atomLanguageTag }?, + undefinedAttribute* + +atomPlainTextConstruct = + atomCommonAttributes, + attribute type { "text" | "html" }?, + text + +atomXHTMLTextConstruct = + atomCommonAttributes, + attribute type { "xhtml" }, + xhtmlDiv + +atomTextConstruct = atomPlainTextConstruct | atomXHTMLTextConstruct + +atomPersonConstruct = + atomCommonAttributes, + (element atom:name { text } + & element atom:uri { atomUri }? + & element atom:email { atomEmailAddress }? + & extensionElement*) + +atomDateConstruct = + atomCommonAttributes, + xsd:dateTime + +atomFeed = + [ + s:rule [ + context = "atom:feed" + s:assert [ + test = "atom:author or not(atom:entry[not(atom:author)])" + "An atom:feed must have an atom:author unless all of its" + ~ "atom:entry children have an atom:author." + ] + ] + ] + element atom:feed { + atomCommonAttributes, + (atomAuthor* + & atomCategory* + & atomContributor* + & atomGenerator? + & atomIcon? + & atomId + & atomLink* + & atomLogo? + & atomRights? + & atomSubtitle? + & atomTitle + & atomUpdated + & extensionElement*), + atomEntry* + } + +atomEntry = + [ + s:rule [ + context = "atom:entry" + s:assert [ + test = "atom:link[@rel='alternate'] " + ~ "or atom:link[not(@rel)] " + ~ "or atom:content" + "An atom:entry must have at least one atom:link element " + ~ "with a rel attribute of 'alternate' " + ~ "or an atom:content." + ] + ] + s:rule [ + context = "atom:entry" + s:assert [ + test = "atom:author or " + ~ "../atom:author or atom:source/atom:author" + "An atom:entry must have an atom:author " + ~ "if its feed does not." + ] + ] + ] + element atom:entry { + atomCommonAttributes, + (atomAuthor* + & atomCategory* + & atomContent? + & atomContributor* + & atomId + & atomLink* + & atomPublished? + & atomRights? + & atomSource? + & atomSummary? + & atomTitle + & atomUpdated + & extensionElement*) + } + +atomInlineTextContent = + element atom:content { + atomCommonAttributes, + attribute type { "text" | "html" }?, + (text)* + } + +atomInlineXHTMLContent = + element atom:content { + atomCommonAttributes, + attribute type { "xhtml" }, + xhtmlDiv + } + +atomInlineOtherContent = + element atom:content { + atomCommonAttributes, + attribute type { atomMediaType }?, + (text|anyElement)* + } + +atomOutOfLineContent = + element atom:content { + atomCommonAttributes, + attribute type { atomMediaType }?, + attribute src { atomUri }, + empty + } + +atomContent = atomInlineTextContent + | atomInlineXHTMLContent + | atomInlineOtherContent + | atomOutOfLineContent + +atomAuthor = element atom:author { atomPersonConstruct } + +atomCategory = + element atom:category { + atomCommonAttributes, + attribute term { text }, + attribute scheme { atomUri }?, + attribute label { text }?, + undefinedContent + } + +atomContributor = element atom:contributor { atomPersonConstruct } + +atomGenerator = element atom:generator { + atomCommonAttributes, + attribute uri { atomUri }?, + attribute version { text }?, + text +} + +atomIcon = element atom:icon { + atomCommonAttributes, + (atomUri) +} + +atomId = element atom:id { + atomCommonAttributes, + (atomUri) +} + +atomLogo = element atom:logo { + atomCommonAttributes, + (atomUri) +} + +atomLink = + element atom:link { + atomCommonAttributes, + attribute href { atomUri }, + attribute rel { atomNCName | atomUri }?, + attribute type { atomMediaType }?, + attribute hreflang { atomLanguageTag }?, + attribute title { text }?, + attribute length { text }?, + undefinedContent + } + +atomPublished = element atom:published { atomDateConstruct } + +atomRights = element atom:rights { atomTextConstruct } + +atomSource = + element atom:source { + atomCommonAttributes, + (atomAuthor* + & atomCategory* + & atomContributor* + & atomGenerator? + & atomIcon? + & atomId? + & atomLink* + & atomLogo? + & atomRights? + & atomSubtitle? + & atomTitle? + & atomUpdated? + & extensionElement*) + } + +atomSubtitle = element atom:subtitle { atomTextConstruct } + +atomSummary = element atom:summary { atomTextConstruct } + +atomTitle = element atom:title { atomTextConstruct } + +atomUpdated = element atom:updated { atomDateConstruct } + +atomNCName = xsd:string { minLength = "1" pattern = "[^:]*" } + +atomMediaType = xsd:string { pattern = ".+/.+" } + +atomLanguageTag = xsd:string { + pattern = "[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*" +} + +atomUri = text + +atomEmailAddress = xsd:string { pattern = ".+@.+" } + +simpleExtensionElement = + element * - atom:* { + text + } + +structuredExtensionElement = + element * - atom:* { + (attribute * { text }+, + (text|anyElement)*) + | (attribute * { text }*, + (text?, anyElement+, (text|anyElement)*)) + } + +extensionElement = + simpleExtensionElement | structuredExtensionElement + +undefinedAttribute = + attribute * - (xml:base | xml:lang | local:*) { text } + +undefinedContent = (text|anyForeignElement)* + +anyElement = + element * { + (attribute * { text } + | text + | anyElement)* + } + +anyForeignElement = + element * - atom:* { + (attribute * { text } + | text + | anyElement)* + } + +anyXHTML = element xhtml:* { + (attribute * { text } + | text + | anyXHTML)* +} + +xhtmlDiv = element xhtml:div { + (attribute * { text } + | text + | anyXHTML)* +}