diff --git a/CHANGES.md b/CHANGES.md index 68a5944c8..0d8517485 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,15 @@ # Apache Roller — Changes +## 7.0.0 (in development) + +### Behaviour changes worth reading before upgrading + +- **AtomPub no longer uses ROME Propono.** Roller now has its own AtomPub + server. The `propono.properties` file and the `RollerAtomHandlerFactory` + extension point are removed, so a deployment that configured its own + `AtomHandlerFactory` (for example, for custom authentication) no longer has + that handler. AtomPub clients see no change. + ## 6.1.7 ### Improvements diff --git a/app/pom.xml b/app/pom.xml index d09ab96ee..e2ddac3ed 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -55,7 +55,7 @@ limitations under the License. 3.5.1 3.5.6 1.0b3 - 1.19.0 + 1.19.0 2.0.19 5.3.39 5.8.16 @@ -529,20 +529,6 @@ limitations under the License. - - - com.rometools - rome-propono - ${rome.version} - compile - - - commons-logging - commons-logging - - - - net.oauth.core oauth-provider @@ -599,6 +585,28 @@ limitations under the License. test + + + com.thaiopensource + jing + 20091111 + test + + + + xerces + xercesImpl + + + xml-apis + xml-apis + + + + diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategories.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategories.java new file mode 100644 index 000000000..02e04c33e --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategories.java @@ -0,0 +1,53 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.List; + +/** + * An APP categories element (app:categories) describing the categories a + * collection accepts. When {@code fixed} is true the listed categories are the + * only ones allowed. + */ +public class AtomCategories { + + private boolean fixed; + private String scheme; + private final List 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 000000000..3a5995000 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCategory.java @@ -0,0 +1,53 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +/** + * An atom:category element. A null {@code scheme} signifies a free-form tag; + * a scheme that matches the weblog category scheme signifies a weblog category. + */ +public class AtomCategory { + + private String term; + private String scheme; + private String label; + + public String getTerm() { + return term; + } + + public void setTerm(String term) { + this.term = term; + } + + public String getScheme() { + return scheme; + } + + public void setScheme(String scheme) { + this.scheme = scheme; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCollection.java new file mode 100644 index 000000000..2b4507f20 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomCollection.java @@ -0,0 +1,60 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.List; + +/** + * An APP collection (app:collection) within a workspace. + */ +public class AtomCollection { + + private String title; + private String href; + private List 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 000000000..0e747f07c --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomConstants.java @@ -0,0 +1,42 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +/** + * Constants shared by the StAX-based AtomPub implementation. + */ +public final class AtomConstants { + + private AtomConstants() { + } + + /** Atom Syndication Format namespace (RFC 4287). */ + public static final String ATOM_NS = "http://www.w3.org/2005/Atom"; + + /** Atom Publishing Protocol namespace (RFC 5023). */ + public static final String APP_NS = "http://www.w3.org/2007/app"; + + /** Media type for an Atom entry. */ + public static final String ENTRY_MEDIA_TYPE = "application/atom+xml;type=entry;charset=utf-8"; + + /** Media type for an Atom feed/collection. */ + public static final String FEED_MEDIA_TYPE = "application/atom+xml;type=feed;charset=utf-8"; + + /** Media type for an APP service document. */ + public static final String SERVICE_MEDIA_TYPE = "application/atomsvc+xml;charset=utf-8"; +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomContent.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomContent.java new file mode 100644 index 000000000..2740f9720 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomContent.java @@ -0,0 +1,54 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +/** + * An Atom text/content construct (atom:content or atom:summary). For inline + * content {@code value} holds the text; for out-of-line media content + * {@code src} holds the source URI instead. + */ +public class AtomContent { + + private String type; + private String value; + private String src; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + public String getSrc() { + return src; + } + + public void setSrc(String src) { + this.src = src; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntry.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntry.java new file mode 100644 index 000000000..96b000567 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntry.java @@ -0,0 +1,141 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * An atom:entry. The {@code draft} and {@code edited} fields carry the APP + * control extension (app:control/app:draft and app:edited). + */ +public class AtomEntry { + + private String id; + private String title; + private AtomContent content; + private AtomContent summary; + private Date published; + private Date updated; + private Date edited; + private boolean draft; + private List 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 000000000..25fb18cfa --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomException.java @@ -0,0 +1,47 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import javax.servlet.http.HttpServletResponse; + +/** + * Base exception for the AtomPub implementation. Carries the HTTP status code + * that the dispatcher servlet should return to the client. + */ +public class AtomException extends Exception { + + private final int status; + + public AtomException(String msg) { + this(msg, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, null); + } + + public AtomException(String msg, Throwable cause) { + this(msg, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, cause); + } + + protected AtomException(String msg, int status, Throwable cause) { + super(msg, cause); + this.status = status; + } + + /** HTTP status code to send to the client for this error. */ + public int getStatus() { + return status; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomFeed.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomFeed.java new file mode 100644 index 000000000..d253f100c --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomFeed.java @@ -0,0 +1,74 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * An atom:feed used to represent an AtomPub collection. + */ +public class AtomFeed { + + private String id; + private String title; + private Date updated; + private List 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 000000000..2d17d5a91 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomLink.java @@ -0,0 +1,60 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +/** + * An atom:link element. + */ +public class AtomLink { + + private String rel; + private String href; + private String type; + + public AtomLink() { + } + + public AtomLink(String rel, String href) { + this.rel = rel; + this.href = href; + } + + public String getRel() { + return rel; + } + + public void setRel(String rel) { + this.rel = rel; + } + + public String getHref() { + return href; + } + + public void setHref(String href) { + this.href = href; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomMediaResource.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomMediaResource.java new file mode 100644 index 000000000..724ef3079 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomMediaResource.java @@ -0,0 +1,63 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.io.InputStream; +import java.util.Date; + +/** + * Holder for the binary data of a media resource, returned when a client GETs + * the edit-media URI so the dispatcher servlet can stream the bytes. + */ +public class AtomMediaResource { + + private final String name; + private final long contentLength; + private final String contentType; + private final Date lastModified; + private final InputStream inputStream; + + public AtomMediaResource(String name, long contentLength, String contentType, + Date lastModified, InputStream inputStream) { + this.name = name; + this.contentLength = contentLength; + this.contentType = contentType; + this.lastModified = lastModified; + this.inputStream = inputStream; + } + + public String getName() { + return name; + } + + public long getContentLength() { + return contentLength; + } + + public String getContentType() { + return contentType; + } + + public Date getLastModified() { + return lastModified; + } + + public InputStream getInputStream() { + return inputStream; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotAuthorizedException.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotAuthorizedException.java new file mode 100644 index 000000000..f568c5b13 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotAuthorizedException.java @@ -0,0 +1,31 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import javax.servlet.http.HttpServletResponse; + +/** + * Thrown when an authenticated user is not permitted to perform the requested + * operation (HTTP 401, matching the previous ROME Propono behavior). + */ +public class AtomNotAuthorizedException extends AtomException { + + public AtomNotAuthorizedException(String msg) { + super(msg, HttpServletResponse.SC_UNAUTHORIZED, null); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotFoundException.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotFoundException.java new file mode 100644 index 000000000..9f0a3e867 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomNotFoundException.java @@ -0,0 +1,30 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import javax.servlet.http.HttpServletResponse; + +/** + * Thrown when a requested resource cannot be found (HTTP 404). + */ +public class AtomNotFoundException extends AtomException { + + public AtomNotFoundException(String msg) { + super(msg, HttpServletResponse.SC_NOT_FOUND, null); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPerson.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPerson.java new file mode 100644 index 000000000..2b713b222 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomPerson.java @@ -0,0 +1,43 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +/** + * An Atom person construct (atom:author). + */ +public class AtomPerson { + + private String name; + private String email; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java new file mode 100644 index 000000000..4940d7821 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReader.java @@ -0,0 +1,285 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.APP_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ATOM_NS; + +import java.io.InputStream; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.Date; + +import javax.servlet.http.HttpServletResponse; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamConstants; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamReader; + +/** + * Parses an incoming AtomPub request body (an atom:entry) into the wire model + * using the JDK StAX API. Replaces ROME's Atom parser. + * + *

DTD processing and external entities are disabled, and an entry that + * declares a DOCTYPE is refused, to protect against XXE attacks. Parse errors + * are reported as HTTP 400. + */ +public class AtomReader { + + private static final String XHTML_NS = "http://www.w3.org/1999/xhtml"; + + private final XMLInputFactory factory; + + public AtomReader() { + factory = XMLInputFactory.newInstance(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE); + } + + static Date parseDate(String text) { + if (text == null || text.isBlank()) { + return null; + } + String trimmed = text.trim(); + try { + return Date.from(OffsetDateTime.parse(trimmed).toInstant()); + } catch (Exception ignored) { + try { + return Date.from(Instant.parse(trimmed)); + } catch (Exception ignored2) { + return null; + } + } + } + + /** + * Parse an atom:entry from the given stream. Only direct children of the + * entry are read, so metadata inside nested elements such as atom:source + * is ignored. The author is intentionally not read; the server sets the + * entry's creator from the authenticated user. + */ + public AtomEntry parseEntry(InputStream in) throws AtomException { + XMLStreamReader r = null; + try { + r = factory.createXMLStreamReader(in, "UTF-8"); + AtomEntry entry = new AtomEntry(); + moveToRootElement(r); + while (r.nextTag() == XMLStreamConstants.START_ELEMENT) { + readEntryChild(r, entry); + } + return entry; + } catch (XMLStreamException ex) { + throw new AtomException("Error parsing Atom entry", + HttpServletResponse.SC_BAD_REQUEST, ex); + } finally { + if (r != null) { + try { + r.close(); + } catch (XMLStreamException ignored) { + // nothing useful to do on close failure + } + } + } + } + + private static void moveToRootElement(XMLStreamReader r) + throws XMLStreamException, AtomException { + while (r.hasNext()) { + int event = r.next(); + if (event == XMLStreamConstants.DTD) { + throw new AtomException("DOCTYPE is not allowed in an Atom entry", + HttpServletResponse.SC_BAD_REQUEST, null); + } + if (event == XMLStreamConstants.START_ELEMENT) { + return; + } + } + throw new XMLStreamException("No root element"); + } + + /** + * Reads one child of atom:entry. On return the reader is on the child's + * END_ELEMENT. + */ + private void readEntryChild(XMLStreamReader r, AtomEntry entry) throws XMLStreamException { + String ns = r.getNamespaceURI(); + String name = r.getLocalName(); + if (ATOM_NS.equals(ns)) { + switch (name) { + case "id": + entry.setId(r.getElementText()); + return; + case "title": + entry.setTitle(readContent(r).getValue()); + return; + case "summary": + entry.setSummary(readContent(r)); + return; + case "content": + AtomContent content = readContent(r); + if (entry.getContent() == null) { + entry.setContent(content); + } + return; + case "published": + entry.setPublished(parseDate(r.getElementText())); + return; + case "updated": + entry.setUpdated(parseDate(r.getElementText())); + return; + case "category": + entry.getCategories().add(readCategory(r)); + skipElement(r); + return; + default: + break; + } + } else if (APP_NS.equals(ns) && "control".equals(name)) { + while (r.nextTag() == XMLStreamConstants.START_ELEMENT) { + if (APP_NS.equals(r.getNamespaceURI()) && "draft".equals(r.getLocalName())) { + String value = r.getElementText(); + entry.setDraft(value != null && value.trim().equalsIgnoreCase("yes")); + } else { + skipElement(r); + } + } + return; + } + skipElement(r); + } + + /** Moves the reader from a START_ELEMENT to its matching END_ELEMENT. */ + private static void skipElement(XMLStreamReader r) throws XMLStreamException { + int depth = 1; + while (depth > 0) { + int event = r.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + depth++; + } else if (event == XMLStreamConstants.END_ELEMENT) { + depth--; + } + } + } + + /** + * Reads an Atom text construct or atom:content. For type="xhtml" the value + * is the markup inside the wrapping xhtml:div (RFC 4287 3.1.1.3). + */ + private AtomContent readContent(XMLStreamReader r) throws XMLStreamException { + AtomContent content = new AtomContent(); + String type = r.getAttributeValue(null, "type"); + content.setType(type); + String src = r.getAttributeValue(null, "src"); + content.setSrc(src); + if (src != null) { + skipElement(r); + } else if ("xhtml".equals(type)) { + content.setValue(readXhtml(r)); + } else { + content.setValue(r.getElementText()); + } + return content; + } + + /** + * Serializes the children of the current element as markup without + * namespace declarations. A single wrapping xhtml:div is left out. Empty + * elements are written as {@code
}, never {@code

}. + */ + private String readXhtml(XMLStreamReader r) throws XMLStreamException { + StringBuilder out = new StringBuilder(); + int depth = 1; + boolean skipDiv = false; + boolean openTag = false; + while (depth > 0) { + int event = r.next(); + if (event == XMLStreamConstants.START_ELEMENT) { + depth++; + if (openTag) { + out.append('>'); + openTag = false; + } + if (depth == 2 && XHTML_NS.equals(r.getNamespaceURI()) + && "div".equals(r.getLocalName()) && out.toString().isBlank()) { + skipDiv = true; + continue; + } + out.append('<').append(r.getLocalName()); + for (int i = 0; i < r.getAttributeCount(); i++) { + out.append(' ').append(r.getAttributeLocalName(i)).append("=\"") + .append(escape(r.getAttributeValue(i), true)).append('"'); + } + openTag = true; + } else if (event == XMLStreamConstants.END_ELEMENT) { + depth--; + if (depth == 0 || (depth == 1 && skipDiv)) { + continue; + } + if (openTag) { + out.append("/>"); + openTag = false; + } else { + out.append("'); + } + } else if (event == XMLStreamConstants.CHARACTERS + || event == XMLStreamConstants.CDATA + || event == XMLStreamConstants.SPACE) { + if (depth > 1 || !skipDiv) { + if (openTag) { + out.append('>'); + openTag = false; + } + out.append(escape(r.getText(), false)); + } + } + } + return out.toString().trim(); + } + + private static String escape(String text, boolean attribute) { + StringBuilder sb = new StringBuilder(text.length()); + for (char c : text.toCharArray()) { + switch (c) { + case '&': + sb.append("&"); + break; + case '<': + sb.append("<"); + break; + case '>': + sb.append(">"); + break; + case '"': + sb.append(attribute ? """ : "\""); + break; + default: + sb.append(c); + } + } + return sb.toString(); + } + + private AtomCategory readCategory(XMLStreamReader r) { + AtomCategory cat = new AtomCategory(); + cat.setTerm(r.getAttributeValue(null, "term")); + cat.setScheme(r.getAttributeValue(null, "scheme")); + cat.setLabel(r.getAttributeValue(null, "label")); + return cat; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java new file mode 100644 index 000000000..a0651fedc --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequest.java @@ -0,0 +1,81 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +import javax.servlet.http.HttpServletRequest; + +/** + * Lightweight wrapper around an {@link HttpServletRequest} for AtomPub handlers. + * The dispatcher servlet reads an Atom entry body once into a byte array and + * passes it here so handlers can read it. Media bodies are not buffered: they + * are streamed from the request (see {@link #streaming(HttpServletRequest)}). + * {@code getPathInfo()} never returns null for the service-document URI. + */ +public class AtomRequest { + + private static final byte[] EMPTY = new byte[0]; + + private final HttpServletRequest request; + private final byte[] body; + private final boolean streaming; + + public AtomRequest(HttpServletRequest request, byte[] body) { + this(request, (body != null) ? body : EMPTY, false); + } + + private AtomRequest(HttpServletRequest request, byte[] body, boolean streaming) { + this.request = request; + this.body = body; + this.streaming = streaming; + } + + /** A request whose body is read directly from the servlet request, once. */ + public static AtomRequest streaming(HttpServletRequest request) { + return new AtomRequest(request, null, true); + } + + /** Path info relative to the AtomPub servlet, never null ("" for the service doc). */ + public String getPathInfo() { + String pathInfo = request.getPathInfo(); + return (pathInfo != null) ? pathInfo : ""; + } + + public String getHeader(String name) { + return request.getHeader(name); + } + + public String getContentType() { + return request.getContentType(); + } + + /** + * A fresh stream over the buffered request body, or the servlet request's + * stream for a streaming request. + */ + public InputStream getInputStream() throws IOException { + return streaming ? request.getInputStream() : new ByteArrayInputStream(body); + } + + public HttpServletRequest getRequest() { + return request; + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java new file mode 100644 index 000000000..4640ac792 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomServiceDoc.java @@ -0,0 +1,33 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.List; + +/** + * An APP service document (app:service). + */ +public class AtomServiceDoc { + + private final List 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 000000000..9ea26e9af --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWorkspace.java @@ -0,0 +1,42 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import java.util.ArrayList; +import java.util.List; + +/** + * An APP workspace (app:workspace) within a service document. + */ +public class AtomWorkspace { + + private String title; + private final List 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 000000000..2c4c7eab1 --- /dev/null +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriter.java @@ -0,0 +1,240 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.APP_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ATOM_NS; + +import java.io.OutputStream; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Date; + +import javax.xml.stream.XMLOutputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.XMLStreamWriter; + +/** + * Serializes the AtomPub wire model ({@link AtomEntry}, {@link AtomFeed}, + * {@link AtomServiceDoc}) to XML using the JDK StAX API. Replaces the ROME and + * Propono serialization the AtomPub server previously relied on. + * + *

Atom entries and feeds are written with the Atom namespace as the default + * namespace (so atom elements are unprefixed) and the APP namespace bound to the + * {@code app} prefix. Service documents use the reverse: APP as the default + * namespace and {@code atom} for atom elements. + */ +public class AtomWriter { + + private static final DateTimeFormatter RFC3339 = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC); + + private final XMLOutputFactory factory = XMLOutputFactory.newInstance(); + + static String formatDate(Date date) { + return date == null ? null : RFC3339.format(date.toInstant()); + } + + public void writeEntry(OutputStream out, AtomEntry entry) throws AtomException { + writeDocument(out, ATOM_NS, "entry", "app", APP_NS, "Error serializing Atom entry", + w -> writeEntryBody(w, entry)); + } + + public void writeFeed(OutputStream out, AtomFeed feed) throws AtomException { + writeDocument(out, ATOM_NS, "feed", "app", APP_NS, "Error serializing Atom feed", w -> { + writeAtomText(w, "id", feed.getId()); + writeAtomText(w, "title", feed.getTitle()); + writeAtomText(w, "updated", formatDate(feed.getUpdated())); + for (AtomLink link : feed.getLinks()) { + writeLink(w, link); + } + for (AtomEntry entry : feed.getEntries()) { + w.writeStartElement(ATOM_NS, "entry"); + writeEntryBody(w, entry); + w.writeEndElement(); + } + }); + } + + public void writeServiceDoc(OutputStream out, AtomServiceDoc service) throws AtomException { + writeDocument(out, APP_NS, "service", "atom", ATOM_NS, "Error serializing service document", w -> { + for (AtomWorkspace workspace : service.getWorkspaces()) { + w.writeStartElement(APP_NS, "workspace"); + writeAtomText(w, "title", workspace.getTitle()); + for (AtomCollection collection : workspace.getCollections()) { + writeCollection(w, collection); + } + w.writeEndElement(); + } + }); + } + + /** Writes the body of a document element. */ + @FunctionalInterface + private interface ElementBody { + void write(XMLStreamWriter w) throws XMLStreamException; + } + + /** + * Writes a UTF-8 document whose root element is in the default namespace + * {@code rootNs}, with {@code otherNs} bound to {@code prefix}. + */ + private void writeDocument(OutputStream out, String rootNs, String root, + String prefix, String otherNs, String errorMessage, ElementBody body) + throws AtomException { + XMLStreamWriter w = null; + try { + w = factory.createXMLStreamWriter(out, "UTF-8"); + w.writeStartDocument("UTF-8", "1.0"); + w.setDefaultNamespace(rootNs); + w.setPrefix(prefix, otherNs); + w.writeStartElement(rootNs, root); + w.writeDefaultNamespace(rootNs); + w.writeNamespace(prefix, otherNs); + body.write(w); + w.writeEndElement(); + w.writeEndDocument(); + w.flush(); + } catch (XMLStreamException ex) { + throw new AtomException(errorMessage, ex); + } finally { + if (w != null) { + try { + w.close(); + } catch (XMLStreamException ignored) { + // nothing useful to do on close failure + } + } + } + } + + private void writeCollection(XMLStreamWriter w, AtomCollection collection) + throws XMLStreamException { + w.writeStartElement(APP_NS, "collection"); + if (collection.getHref() != null) { + w.writeAttribute("href", collection.getHref()); + } + writeAtomText(w, "title", collection.getTitle()); + for (String accept : collection.getAccepts()) { + w.writeStartElement(APP_NS, "accept"); + w.writeCharacters(accept); + w.writeEndElement(); + } + for (AtomCategories cats : collection.getCategories()) { + w.writeStartElement(APP_NS, "categories"); + w.writeAttribute("fixed", cats.isFixed() ? "yes" : "no"); + if (cats.getScheme() != null) { + w.writeAttribute("scheme", cats.getScheme()); + } + for (AtomCategory cat : cats.getCategories()) { + writeCategory(w, cat); + } + w.writeEndElement(); + } + w.writeEndElement(); + } + + private void writeEntryBody(XMLStreamWriter w, AtomEntry entry) throws XMLStreamException { + writeAtomText(w, "id", entry.getId()); + writeAtomText(w, "title", entry.getTitle()); + writeAtomText(w, "published", formatDate(entry.getPublished())); + writeAtomText(w, "updated", formatDate(entry.getUpdated())); + for (AtomPerson author : entry.getAuthors()) { + w.writeStartElement(ATOM_NS, "author"); + writeAtomText(w, "name", author.getName()); + writeAtomText(w, "email", author.getEmail()); + w.writeEndElement(); + } + for (AtomCategory cat : entry.getCategories()) { + writeCategory(w, cat); + } + if (entry.getSummary() != null) { + writeContent(w, "summary", entry.getSummary()); + } + if (entry.getContent() != null) { + writeContent(w, "content", entry.getContent()); + } + for (AtomLink link : entry.getLinks()) { + writeLink(w, link); + } + // APP extensions (RFC 5023): app:edited is a child of atom:entry + // (section 10.2); app:draft goes inside app:control (section 13.1) + if (entry.getEdited() != null) { + w.writeStartElement(APP_NS, "edited"); + w.writeCharacters(formatDate(entry.getEdited())); + w.writeEndElement(); + } + w.writeStartElement(APP_NS, "control"); + w.writeStartElement(APP_NS, "draft"); + w.writeCharacters(entry.isDraft() ? "yes" : "no"); + w.writeEndElement(); + w.writeEndElement(); + } + + private void writeContent(XMLStreamWriter w, String name, AtomContent content) + throws XMLStreamException { + w.writeStartElement(ATOM_NS, name); + if (content.getType() != null) { + w.writeAttribute("type", content.getType()); + } + if (content.getSrc() != null) { + w.writeAttribute("src", content.getSrc()); + } else if (content.getValue() != null) { + w.writeCharacters(content.getValue()); + } + w.writeEndElement(); + } + + private void writeLink(XMLStreamWriter w, AtomLink link) throws XMLStreamException { + w.writeStartElement(ATOM_NS, "link"); + if (link.getRel() != null) { + w.writeAttribute("rel", link.getRel()); + } + if (link.getHref() != null) { + w.writeAttribute("href", link.getHref()); + } + if (link.getType() != null) { + w.writeAttribute("type", link.getType()); + } + w.writeEndElement(); + } + + private void writeCategory(XMLStreamWriter w, AtomCategory cat) throws XMLStreamException { + w.writeStartElement(ATOM_NS, "category"); + if (cat.getTerm() != null) { + w.writeAttribute("term", cat.getTerm()); + } + if (cat.getScheme() != null) { + w.writeAttribute("scheme", cat.getScheme()); + } + if (cat.getLabel() != null) { + w.writeAttribute("label", cat.getLabel()); + } + w.writeEndElement(); + } + + private void writeAtomText(XMLStreamWriter w, String name, String text) + throws XMLStreamException { + if (text == null) { + return; + } + w.writeStartElement(ATOM_NS, name); + w.writeCharacters(text); + w.writeEndElement(); + } +} diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/EntryCollection.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/EntryCollection.java index e637ffc7d..d65daaa82 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/EntryCollection.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/EntryCollection.java @@ -1,13 +1,13 @@ /* * Copyright 2007 Sun Microsystems, Inc. All rights reserved. * Use is subject to license terms. - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you * may not use this file except in compliance with the License. You may * obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -18,21 +18,6 @@ package org.apache.roller.weblogger.webservices.atomprotocol; -import com.rometools.propono.atom.common.rome.AppModule; -import com.rometools.propono.atom.common.rome.AppModuleImpl; -import com.rometools.propono.atom.server.AtomException; -import com.rometools.propono.atom.server.AtomNotAuthorizedException; -import com.rometools.propono.atom.server.AtomNotFoundException; -import com.rometools.propono.atom.server.AtomRequest; -import com.rometools.rome.feed.atom.Category; -import com.rometools.rome.feed.atom.Content; -import com.rometools.rome.feed.atom.Entry; -import com.rometools.rome.feed.atom.Feed; -import com.rometools.rome.feed.atom.Link; -import com.rometools.rome.feed.atom.Person; -import com.rometools.rome.feed.module.Module; -import com.rometools.rome.feed.synd.SyndPerson; - import java.sql.Timestamp; import java.util.ArrayList; import java.util.Collections; @@ -70,26 +55,26 @@ public class EntryCollection { private Weblogger roller; private User user; private static final int MAX_ENTRIES = 20; - private final String atomURL; - + private final String atomURL; + private static Log log = LogFactory.getFactory().getInstance(EntryCollection.class); - - + + public EntryCollection(User user, String atomURL) { this.user = user; this.atomURL = atomURL; this.roller = WebloggerFactory.getWeblogger(); } - - - public Entry postEntry(AtomRequest areq, Entry entry) throws AtomException { + + + public AtomEntry postEntry(AtomRequest areq, AtomEntry entry) throws AtomException { log.debug("Entering"); String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); try { // authenticated client posted a weblog entry String handle = pathInfo[0]; - Weblog website = + Weblog website = roller.getWeblogManager().getWeblogByHandle(handle); if (website == null) { throw new AtomNotFoundException("Cannot find weblog: " + handle); @@ -97,9 +82,9 @@ public 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 1081b6544..7626ff701 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollection.java @@ -1,13 +1,13 @@ /* * Copyright 2007 Sun Microsystems, Inc. All rights reserved. * Use is subject to license terms. - * + * * Licensed under the Apache License, Version 2.0 (the "License"); you * may not use this file except in compliance with the License. You may * obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -17,17 +17,6 @@ package org.apache.roller.weblogger.webservices.atomprotocol; -import com.rometools.propono.atom.common.rome.AppModule; -import com.rometools.propono.atom.common.rome.AppModuleImpl; -import com.rometools.propono.atom.server.AtomException; -import com.rometools.propono.atom.server.AtomMediaResource; -import com.rometools.propono.atom.server.AtomNotAuthorizedException; -import com.rometools.propono.atom.server.AtomNotFoundException; -import com.rometools.propono.atom.server.AtomRequest; -import com.rometools.rome.feed.atom.Content; -import com.rometools.rome.feed.atom.Entry; -import com.rometools.rome.feed.atom.Feed; -import com.rometools.rome.feed.atom.Link; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; @@ -35,7 +24,6 @@ import java.io.InputStream; import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; @@ -74,12 +62,12 @@ public class MediaCollection { private User user; private HttpServletResponse response; private static final int MAX_ENTRIES = 20; - private final String atomURL; - + private final String atomURL; + private static Log log = LogFactory.getFactory().getInstance(EntryCollection.class); - - + + public MediaCollection(User user, String atomURL) { this(user, atomURL, null); } @@ -89,10 +77,10 @@ public MediaCollection(User user, String atomURL, HttpServletResponse response) this.atomURL = atomURL; this.response = response; this.roller = WebloggerFactory.getWeblogger(); - } - - - public Entry postMedia(AtomRequest areq, Entry entry) throws AtomException { + } + + + public AtomEntry postMedia(AtomRequest areq, AtomEntry entry) throws AtomException { log.debug("Entering"); String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); @@ -100,11 +88,11 @@ public 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]; @@ -126,7 +114,7 @@ public Entry postMedia(AtomRequest areq, Entry entry) throws AtomException { FileOutputStream fos = new FileOutputStream(tempFile); Utilities.copyInputToOutput(is, fos); fos.close(); - + // Parse pathinfo to determine file path String path = filePathFromPathInfo(pathInfo); String justPath = path; @@ -171,20 +159,17 @@ public 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); @@ -195,16 +180,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(), "/"); @@ -212,6 +197,12 @@ public Entry getEntry(AtomRequest areq) throws AtomException { filePath = filePath.substring(0, filePath.length() - ".media-link".length()); String handle = pathInfo[0]; Weblog website = roller.getWeblogManager().getWeblogByHandle(handle); + if (website == null) { + throw new AtomNotFoundException("Cannot find weblog: " + handle); + } + if (!RollerAtomHandler.canView(user, website)) { + throw new AtomNotAuthorizedException("Not authorized to access website"); + } MediaFileManager fileMgr = roller.getMediaFileManager(); MediaFile mf = fileMgr.getMediaFileByPath(website, filePath); @@ -220,14 +211,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(),"/"); @@ -240,25 +231,30 @@ 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); + if (mf == null) { + throw new AtomNotFoundException("Cannot find media file: " + filePath); + } return createMediaResource(mf, response); + } catch (AtomException ae) { + throw ae; } catch (Exception e) { throw new AtomException( "Unexpected error during file upload", e); } } throw new AtomException("Incorrect path information"); - + } catch (WebloggerException re) { throw new AtomException("Posting media"); } } - - - public Feed getCollection(AtomRequest areq) throws AtomException { + + + public AtomFeed getCollection(AtomRequest areq) throws AtomException { log.debug("Entering"); String[] rawPathInfo = StringUtils.split(areq.getPathInfo(),"/"); try { @@ -284,16 +280,17 @@ public 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; @@ -325,7 +322,7 @@ else if (f1.getLastModified() == f2.getLastModified()) { } } }); - + if (files != null && start < files.size()) { for (MediaFile mf : files) { sortedSet.add(mf); @@ -333,9 +330,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 @@ -344,28 +341,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); @@ -373,23 +368,22 @@ 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]; @@ -402,18 +396,22 @@ public void putMedia(AtomRequest areq) throws AtomException { if (pathInfo.length > 1) { // Save to temp file try { - tempFile = File.createTempFile(UUID.randomUUID().toString(), "tmp"); - FileOutputStream fos = new FileOutputStream(tempFile); - Utilities.copyInputToOutput(is, fos); - fos.close(); - - FileInputStream fis = new FileInputStream(tempFile); - // Parse pathinfo to determine file path String path = filePathFromPathInfo(pathInfo); - + // Attempt to load file, to ensure it exists MediaFile mf = fmgr.getMediaFileByPath(website, path); + if (mf == null) { + throw new AtomNotFoundException("Cannot find media file: " + path); + } + + tempFile = File.createTempFile(UUID.randomUUID().toString(), "tmp"); + try (InputStream is = areq.getInputStream(); + FileOutputStream fos = new FileOutputStream(tempFile)) { + Utilities.copyInputToOutput(is, fos); + } + + FileInputStream fis = new FileInputStream(tempFile); String replacementName = pathInfo[pathInfo.length - 1]; String declaredType = MediaTypePolicy.normalizeType(contentType); RollerMessages errors = new RollerMessages(); @@ -432,10 +430,12 @@ public void putMedia(AtomRequest areq) throws AtomException { roller.flush(); fis.close(); - + log.debug("Exiting"); return; + } catch (AtomException ae) { + throw ae; } catch (FileIOException fie) { throw new AtomException( "File upload disabled, over-quota or other error", fie); @@ -449,15 +449,13 @@ public 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(), "/"); @@ -468,30 +466,36 @@ public void deleteEntry(AtomRequest areq) throws AtomException { } if (RollerAtomHandler.canEdit(user, website) && pathInfo.length > 1) { try { + // The edit URI ends in .media-link; the edit-media URI does not String path = filePathFromPathInfo(pathInfo); - String fileName = path.substring(0, path.length() - ".media-link".length()); + String fileName = StringUtils.removeEnd(path, ".media-link"); MediaFileManager fmgr = roller.getMediaFileManager(); - MediaFile mf = fmgr.getMediaFileByPath(website, path); + MediaFile mf = fmgr.getMediaFileByPath(website, fileName); + if (mf == null) { + throw new AtomNotFoundException("Cannot find media file: " + fileName); + } fmgr.removeMediaFile(website, mf); log.debug("Deleted media entry: " + fileName); return; - + + } catch (AtomException ae) { + throw ae; } catch (Exception e) { String msg = "ERROR deleting media entry"; log.error(msg, e); throw new AtomException(msg); } } - log.debug("Not authorized to delete media entry"); - log.debug("Exiting via exception"); + log.debug("Not authorized to delete media entry"); + log.debug("Exiting via exception"); } catch (WebloggerException ex) { throw new AtomException("ERROR deleting media entry",ex); } throw new AtomNotAuthorizedException("Not authorized to delete entry"); } - - + + private String filePathFromPathInfo(String[] pathInfo) { String path = null; if (pathInfo.length > 2) { @@ -511,88 +515,79 @@ private String filePathFromPathInfo(String[] pathInfo) { static AtomMediaResource createMediaResource(MediaFile mediaFile, HttpServletResponse response) throws IOException { - AtomMediaResource resource = new AtomMediaResource( - mediaFile.getName(), mediaFile.getLength(), - new Date(mediaFile.getLastModified()), mediaFile.getInputStream()); - resource.setContentType(mediaFile.getContentType()); + String contentType = mediaFile.getContentType(); if (response != null) { MediaTypePolicy.applyResponseHeaders(response, mediaFile.getContentType(), mediaFile.getName()); - resource.setContentType( - MediaTypePolicy.responseTypeFor(mediaFile.getContentType())); + contentType = MediaTypePolicy.responseTypeFor(mediaFile.getContentType()); } - return resource; + return new AtomMediaResource(mediaFile.getName(), mediaFile.getLength(), + contentType, new Date(mediaFile.getLastModified()), + mediaFile.getInputStream()); } - - private Entry createAtomResourceEntry(Weblog website, MediaFile file) { + + private AtomEntry createAtomResourceEntry(Weblog website, MediaFile file) { String filePath = file.getPath().endsWith("/") ? file.getPath() + file.getName() : file.getPath() + "/" + file.getName(); - String editURI = + String editURI = atomURL+"/"+website.getHandle() + "/resource/" + filePath + ".media-link"; - String editMediaURI = + String editMediaURI = atomURL+"/"+ website.getHandle() + "/resource/" + filePath; String contentType = Utilities.getContentTypeFromFileName(file.getName()); - - Entry entry = new Entry(); + + AtomEntry entry = new AtomEntry(); entry.setId(editMediaURI); entry.setTitle(file.getName()); entry.setUpdated(new Date(file.getLastModified())); - - Link altlink = new Link(); + + List 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" @@ -607,23 +602,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); @@ -639,15 +634,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 52c8336c8..2844bd1ef 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandler.java @@ -16,42 +16,36 @@ * directory of this distribution. */ package org.apache.roller.weblogger.webservices.atomprotocol; -import com.rometools.propono.atom.common.Categories; -import com.rometools.propono.atom.server.AtomRequest; + import java.util.StringTokenizer; import java.util.Locale; import javax.servlet.http.HttpServletRequest; -import org.apache.commons.codec.binary.Base64; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.apache.roller.util.RollerConstants; -import org.apache.roller.weblogger.business.Weblogger; -import org.apache.roller.weblogger.business.WebloggerFactory; -import org.apache.roller.weblogger.pojos.User; -import org.apache.roller.weblogger.pojos.WeblogEntry; -import org.apache.roller.weblogger.pojos.Weblog; -import com.rometools.propono.atom.common.AtomService; -import com.rometools.propono.atom.server.AtomException; -import com.rometools.propono.atom.server.AtomHandler; -import com.rometools.propono.atom.server.AtomMediaResource; -import com.rometools.propono.atom.server.AtomNotFoundException; -import com.rometools.rome.feed.atom.Entry; -import com.rometools.rome.feed.atom.Feed; import javax.servlet.http.HttpServletResponse; import net.oauth.OAuthAccessor; import net.oauth.OAuthMessage; import net.oauth.server.OAuthServlet; +import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.roller.util.RollerConstants; import org.apache.roller.weblogger.WebloggerException; import org.apache.roller.weblogger.business.OAuthManager; +import org.apache.roller.weblogger.business.Weblogger; +import org.apache.roller.weblogger.business.WebloggerFactory; import org.apache.roller.weblogger.config.WebloggerConfig; import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; +import org.apache.roller.weblogger.pojos.User; +import org.apache.roller.weblogger.pojos.Weblog; +import org.apache.roller.weblogger.pojos.WeblogEntry; import org.apache.roller.weblogger.pojos.WeblogPermission; import org.apache.roller.weblogger.ui.core.RollerContext; /** - * Weblogger's ROME Propono-based Atom Protocol implementation. + * Weblogger's Atom Publishing Protocol implementation. This implementation uses + * only the JDK XML (StAX) APIs for serialization and parsing — it does not + * depend on ROME or Propono. * * Each Weblogger workspace has two collections, one that accepts entries and * that accepts everything. The entries collection represents the weblog @@ -90,7 +84,7 @@ * * @author David M Johnson */ -public class RollerAtomHandler implements AtomHandler { +public class RollerAtomHandler { protected Weblogger roller = null; protected User user = null; protected int maxEntries = 20; @@ -145,10 +139,14 @@ public RollerAtomHandler(HttpServletRequest request, HttpServletResponse respons atomURL = WebloggerFactory.getWeblogger().getUrlStrategy().getAtomProtocolURL(true); } + /** The absolute AtomPub URL that this server's links start with. */ + String getAtomURL() { + return atomURL; + } + /** * Return weblogHandle of authenticated user or null if there is none. */ - @Override public String getAuthenticatedUsername() { String ret = null; if (this.user != null) { @@ -163,10 +161,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); @@ -178,8 +175,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); } @@ -187,12 +183,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); @@ -211,8 +203,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")) { @@ -227,17 +218,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 @@ -256,7 +240,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, response); return mcol.getMediaResource(areq); @@ -268,8 +251,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); } @@ -279,7 +261,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); @@ -291,7 +272,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(),"/"); @@ -316,7 +296,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; @@ -325,7 +304,6 @@ public boolean isAtomServiceURI(AtomRequest areq) { /** * True if URL is a entry URI. */ - @Override public boolean isEntryURI(AtomRequest areq) { return isEntryPath(areq.getPathInfo()); } @@ -351,7 +329,6 @@ static boolean isEntryPath(String path) { /** * True if URL is media edit URI. Media can be updated, but not metadata. */ - @Override public boolean isMediaEditURI(AtomRequest areq) { String[] pathInfo = StringUtils.split(areq.getPathInfo(),"/"); if (pathInfo.length > 1 && pathInfo[1].equals("resource")) { @@ -363,7 +340,6 @@ public 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")) { @@ -378,11 +354,6 @@ public boolean isCollectionURI(AtomRequest areq) { return false; } - @Override - public boolean isCategoriesURI(AtomRequest arg0) { - return false; - } - //------------------------------------------------------------------ permissions @@ -447,8 +418,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, inUser.getPassword()); + valid = RollerContext.getPasswordEncoder().matches(password, inUser.getPassword()); } } } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java deleted file mode 100644 index 29d5bb06b..000000000 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomHandlerFactory.java +++ /dev/null @@ -1,47 +0,0 @@ -/* -* Licensed to the Apache Software Foundation (ASF) under one or more -* contributor license agreements. The ASF licenses this file to You -* under the Apache License, Version 2.0 (the "License"); you may not -* use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. For additional information regarding -* copyright in this work, please see the NOTICE file in the top level -* directory of this distribution. -*/ - -package org.apache.roller.weblogger.webservices.atomprotocol; - -import com.rometools.propono.atom.server.AtomHandlerFactory; -import com.rometools.propono.atom.server.AtomHandler; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -/** - * Extends {@link com.rometools.propono.atom.server.AtomHandlerFactory} to create and return - * {@link com.rometools.propono.atom.server.impl.FileBasedAtomHandler}. - */ -public class RollerAtomHandlerFactory extends AtomHandlerFactory { - - /** - * Return the handler that {@link RollerAtomServlet} already authenticated - * for this request, or create a new AtomHandler. - */ - @Override - public AtomHandler newAtomHandler( - HttpServletRequest req, HttpServletResponse res) { - Object handler = req.getAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE); - if (handler instanceof AtomHandler) { - req.removeAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE); - return (AtomHandler) handler; - } - return new RollerAtomHandler(req, res); - } -} - diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomService.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomService.java index 60342c63c..74094efba 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomService.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomService.java @@ -36,30 +36,24 @@ import org.apache.roller.weblogger.pojos.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 index 909a74ec3..4c765ec98 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServlet.java @@ -1,60 +1,54 @@ /* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. The ASF licenses this file to You - * under the Apache License, Version 2.0 (the "License"); you may not - * use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. For additional information regarding - * copyright in this work, please see the NOTICE file in the top level - * directory of this distribution. - */ - +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ package org.apache.roller.weblogger.webservices.atomprotocol; -import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import javax.xml.parsers.ParserConfigurationException; -import javax.servlet.ReadListener; -import javax.servlet.ServletException; -import javax.servlet.ServletInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; + +import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; -import com.rometools.propono.atom.server.AtomHandler; -import com.rometools.propono.atom.server.AtomServlet; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; -import org.apache.roller.weblogger.util.SecureXmlParsers; -import org.xml.sax.InputSource; -import org.xml.sax.SAXException; -import org.xml.sax.SAXParseException; -import org.xml.sax.XMLReader; -import org.xml.sax.ext.DefaultHandler2; -import org.xml.sax.helpers.DefaultHandler; /** - * Roller's AtomPub endpoint. It answers only while - * webservices.enableAtomPub is on, and it reads each Atom entry - * body with Roller's shared XML parser settings before the Propono servlet - * handles the request. + * Dispatcher servlet for Roller's Atom Publishing Protocol (RFC 5023) + * implementation. Replaces the ROME Propono {@code AtomServlet}: it + * authenticates the request, routes by HTTP method and URI shape to + * {@link RollerAtomHandler}, and serializes/parses Atom XML via {@link AtomWriter} + * and {@link AtomReader}. No ROME or Propono types are involved. + * + *

It answers only while webservices.enableAtomPub is on. Atom + * entry bodies are capped at webservices.atomPubMaxEntrySize bytes + * and are read only after the request is authenticated. */ -public class RollerAtomServlet extends AtomServlet { +public class RollerAtomServlet extends HttpServlet { private static final long serialVersionUID = 1L; - private static final Log LOG = LogFactory.getLog(RollerAtomServlet.class); + private static final Log log = + LogFactory.getFactory().getInstance(RollerAtomServlet.class); /** Default maximum Atom entry body size, in bytes. Media uploads are not affected. */ static final int DEFAULT_MAX_ENTRY_BYTES = 1024 * 1024; @@ -63,102 +57,134 @@ public class RollerAtomServlet extends AtomServlet { private static final String ATOM_CONTENT_TYPE = "application/atom+xml"; - /** - * Request attribute that carries the handler authenticated by this servlet - * to {@link RollerAtomHandlerFactory}, so Propono does not authenticate the - * request a second time. - */ - static final String HANDLER_ATTRIBUTE = RollerAtomServlet.class.getName() + ".handler"; - @Override - protected void service(HttpServletRequest req, HttpServletResponse res) - throws ServletException, IOException { + protected void service(HttpServletRequest request, HttpServletResponse response) + throws IOException { if (!WebloggerRuntimeConfig.getBooleanProperty("webservices.enableAtomPub")) { - LOG.debug("AtomPub service is disabled; rejecting request"); - sendText(res, HttpServletResponse.SC_NOT_FOUND, "AtomPub service is disabled"); + log.debug("AtomPub service is disabled; rejecting request"); + sendText(response, HttpServletResponse.SC_NOT_FOUND, "AtomPub service is disabled"); return; } - if (!carriesEntry(req)) { - forward(req, res); + String method = request.getMethod(); + if (!"GET".equals(method) && !"POST".equals(method) + && !"PUT".equals(method) && !"DELETE".equals(method)) { + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); return; } - // Authenticate before reading the body, as Propono does. - AtomHandler handler = createHandler(req, res); - if (handler.getAuthenticatedUsername() == null) { - res.setHeader("WWW-Authenticate", "BASIC realm=\"AtomPub\""); - res.sendError(HttpServletResponse.SC_UNAUTHORIZED); + // Authenticate before reading the body. + RollerAtomHandler handler = createHandler(request, response); + String userName = handler.getAuthenticatedUsername(); + if (userName == null) { + // The OAuth path may have already written a challenge/error response. + if (!response.isCommitted()) { + response.setHeader("WWW-Authenticate", "Basic realm=\"Roller\""); + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } return; } - req.setAttribute(HANDLER_ATTRIBUTE, handler); - int maxEntryBytes = maxEntryBytes(); - // Read one byte past the limit, so an oversized body can be detected. - byte[] body = req.getInputStream().readNBytes(maxEntryBytes + 1); - if (body.length > maxEntryBytes) { - sendText(res, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "Entry is too large"); + if ("POST".equals(method) && request.getContentType() == null) { + sendText(response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, + "No content-type specified in request"); return; } - DefaultHandler contentHandler = new DefaultHandler() { - @Override - public void error(SAXParseException e) throws SAXException { - throw e; + + AtomRequest areq; + AtomEntry entry = null; + if (carriesEntry(request)) { + int maxEntryBytes = maxEntryBytes(); + // Read one byte past the limit, so an oversized body can be detected. + byte[] body = request.getInputStream().readNBytes(maxEntryBytes + 1); + if (body.length > maxEntryBytes) { + sendText(response, HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, + "Entry is too large"); + return; + } + try { + entry = new AtomReader().parseEntry(new ByteArrayInputStream(body)); + } catch (AtomException e) { + log.debug("Rejecting Atom entry that could not be parsed", e); + sendText(response, HttpServletResponse.SC_BAD_REQUEST, "Invalid Atom entry"); + return; } - }; - XMLReader reader; + areq = new AtomRequest(request, body); + } else { + // Media bodies are streamed to a temporary file by MediaCollection, + // where the upload size and quota are checked. + areq = AtomRequest.streaming(request); + } + try { - reader = SecureXmlParsers.newSAXParserFactory().newSAXParser().getXMLReader(); - // Hardening: DOCTYPE declarations should be rejected, - // regardless whether the secure reader already does it. - DefaultHandler2 doctypeRefuser = new DefaultHandler2() { - @Override - public void startDTD(String name, String publicId, String systemId) - throws SAXException { - throw new SAXException("DOCTYPE is not allowed in an Atom entry"); + switch (method) { + case "GET": + doGet(handler, areq, response); + break; + case "POST": + doPost(handler, areq, entry, response); + break; + case "PUT": + doPut(handler, areq, entry, response); + break; + default: + handler.deleteEntry(areq); + response.setStatus(HttpServletResponse.SC_OK); + } + } catch (AtomException ae) { + if (!response.isCommitted()) { + if (ae.getStatus() >= HttpServletResponse.SC_INTERNAL_SERVER_ERROR) { + // Server errors can carry internal details; keep them in the log + log.error("Error handling AtomPub request", ae); + response.sendError(ae.getStatus()); + } else { + // Client errors carry a message written by Roller for the client + log.debug("Returning error to client: " + ae.getMessage(), ae); + response.sendError(ae.getStatus(), ae.getMessage()); } - }; - reader.setProperty("http://xml.org/sax/properties/lexical-handler", doctypeRefuser); - } catch (ParserConfigurationException | SAXException e) { - throw new ServletException("Could not create an Atom entry parser", e); + } + } catch (Exception e) { + log.error("Unexpected error handling AtomPub request", e); + if (!response.isCommitted()) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + } + + /** + * Returns the edit URI as a header value, or null when it is not a valid + * URI under this server's AtomPub URL. The URI is built in part from + * client input (the Slug header and the request path), so this keeps the + * Location header from pointing elsewhere or carrying CR/LF. + */ + static String safeLocation(String href, String atomURL) { + if (href == null || atomURL == null || !href.startsWith(atomURL + "/")) { + return null; } - reader.setContentHandler(contentHandler); - reader.setErrorHandler(contentHandler); try { - // Propono reads the entry as UTF-8 text, so check the same text. - reader.parse(new InputSource(new InputStreamReader( - new ByteArrayInputStream(body), StandardCharsets.UTF_8))); - } catch (SAXException e) { - LOG.debug("Rejecting Atom entry that could not be parsed", e); - sendText(res, HttpServletResponse.SC_BAD_REQUEST, "Invalid Atom entry"); - return; + return new URI(href).toASCIIString(); + } catch (URISyntaxException e) { + return null; } - forward(new BufferedBodyRequest(req, body), res); } /** Creates the handler that authenticates the request. */ - protected AtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) { + protected RollerAtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) { return new RollerAtomHandler(req, res); } - /** Hands the request to the Propono servlet. */ - protected void forward(HttpServletRequest req, HttpServletResponse res) - throws ServletException, IOException { - super.service(req, res); - } - /** - * True when Propono would parse the request body as an Atom entry: a POST - * of Atom content, or a PUT to an entry URI. + * True when the request body is an Atom entry: a POST of Atom content, or + * a PUT to an entry URI. */ static boolean carriesEntry(HttpServletRequest req) { String method = req.getMethod(); - if ("POST".equalsIgnoreCase(method)) { + if ("POST".equals(method)) { String contentType = req.getContentType(); return contentType != null && contentType.startsWith(ATOM_CONTENT_TYPE); } - if ("PUT".equalsIgnoreCase(method)) { + if ("PUT".equals(method)) { return RollerAtomHandler.isEntryPath(req.getPathInfo()); } return false; @@ -176,7 +202,7 @@ private static int maxEntryBytes() { } catch (NumberFormatException e) { // Fall back to the default below. } - LOG.warn("Invalid " + MAX_ENTRY_SIZE_PROPERTY + "; using the default entry limit"); + log.warn("Invalid " + MAX_ENTRY_SIZE_PROPERTY + "; using the default entry limit"); } return DEFAULT_MAX_ENTRY_BYTES; } @@ -188,61 +214,94 @@ private static void sendText(HttpServletResponse res, int status, String message res.getWriter().write(message); } - /** A request whose body has already been read into memory. */ - static final class BufferedBodyRequest extends HttpServletRequestWrapper { + private void doGet(RollerAtomHandler handler, AtomRequest areq, HttpServletResponse response) + throws AtomException, IOException { - private final byte[] body; + if (handler.isAtomServiceURI(areq)) { + AtomServiceDoc service = handler.getAtomService(areq); + response.setContentType(AtomConstants.SERVICE_MEDIA_TYPE); + new AtomWriter().writeServiceDoc(response.getOutputStream(), service); - BufferedBodyRequest(HttpServletRequest request, byte[] body) { - super(request); - this.body = body; - } + } else if (handler.isCollectionURI(areq)) { + AtomFeed feed = handler.getCollection(areq); + response.setContentType(AtomConstants.FEED_MEDIA_TYPE); + new AtomWriter().writeFeed(response.getOutputStream(), feed); - @Override - public ServletInputStream getInputStream() { - final ByteArrayInputStream in = new ByteArrayInputStream(body); - return new ServletInputStream() { - @Override - public int read() { - return in.read(); - } + } else if (handler.isEntryURI(areq)) { + AtomEntry entry = handler.getEntry(areq); + response.setContentType(AtomConstants.ENTRY_MEDIA_TYPE); + new AtomWriter().writeEntry(response.getOutputStream(), entry); - @Override - public int read(byte[] b, int off, int len) { - return in.read(b, off, len); - } + } else if (handler.isMediaEditURI(areq)) { + AtomMediaResource resource = handler.getMediaResource(areq); + if (resource.getContentType() != null) { + response.setContentType(resource.getContentType()); + } + response.setContentLengthLong(resource.getContentLength()); + if (resource.getLastModified() != null) { + response.setDateHeader("Last-Modified", resource.getLastModified().getTime()); + } + try (InputStream in = resource.getInputStream()) { + in.transferTo(response.getOutputStream()); + } - @Override - public boolean isFinished() { - return in.available() == 0; - } + } else { + throw new AtomNotFoundException("Cannot find specified resource"); + } + } - @Override - public boolean isReady() { - return true; - } + private void doPost(RollerAtomHandler handler, AtomRequest areq, AtomEntry entry, + HttpServletResponse response) throws AtomException { - @Override - public void setReadListener(ReadListener listener) { - throw new UnsupportedOperationException(); - } - }; + if (!handler.isCollectionURI(areq)) { + throw new AtomNotFoundException("Cannot POST to specified URI"); } - @Override - public BufferedReader getReader() { - return new BufferedReader(new InputStreamReader( - new ByteArrayInputStream(body), StandardCharsets.UTF_8)); + String contentType = areq.getContentType(); + AtomEntry created; + if (entry != null) { + created = handler.postEntry(areq, entry); + } else { + // Media POST: synthesize an entry carrying the request content type + // and Slug; the binary data is read from the request body. + AtomEntry mediaEntry = new AtomEntry(); + AtomContent content = new AtomContent(); + content.setType(contentType); + mediaEntry.setContent(content); + mediaEntry.setTitle(areq.getHeader("Slug")); + created = handler.postMedia(areq, mediaEntry); } + writeCreated(handler, response, created); + } + + private void doPut(RollerAtomHandler handler, AtomRequest areq, AtomEntry entry, + HttpServletResponse response) throws AtomException { - @Override - public int getContentLength() { - return body.length; + if (entry != null) { + handler.putEntry(areq, entry); + response.setStatus(HttpServletResponse.SC_OK); + } else if (handler.isMediaEditURI(areq)) { + handler.putMedia(areq); + response.setStatus(HttpServletResponse.SC_OK); + } else { + throw new AtomNotFoundException("Cannot PUT to specified URI"); } + } - @Override - public long getContentLengthLong() { - return body.length; + private void writeCreated(RollerAtomHandler handler, HttpServletResponse response, + AtomEntry entry) throws AtomException { + String location = safeLocation(entry.getLinkHref("edit"), handler.getAtomURL()); + if (location != null) { + response.setHeader("Location", location); + response.setHeader("Content-Location", location); + } + response.setStatus(HttpServletResponse.SC_CREATED); + response.setContentType(AtomConstants.ENTRY_MEDIA_TYPE); + try { + OutputStream out = response.getOutputStream(); + new AtomWriter().writeEntry(out, entry); + } catch (IOException ioe) { + throw new AtomException("Error writing created entry", ioe); } } } diff --git a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/package-info.java b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/package-info.java index 412ae3a32..b76e13aac 100644 --- a/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/package-info.java +++ b/app/src/main/java/org/apache/roller/weblogger/webservices/atomprotocol/package-info.java @@ -17,7 +17,8 @@ */ /** -

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/propono.properties b/app/src/main/resources/propono.properties deleted file mode 100644 index b15f9e470..000000000 --- a/app/src/main/resources/propono.properties +++ /dev/null @@ -1,18 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. The ASF licenses this file to You -# under the Apache License, Version 2.0 (the "License"); you may not -# use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. For additional information regarding -# copyright in this work, please see the NOTICE file in the top level -# directory of this distribution. - -com.rometools.propono.atom.server.AtomHandlerFactory=\ -org.apache.roller.weblogger.webservices.atomprotocol.RollerAtomHandlerFactory diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntryTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntryTest.java new file mode 100644 index 000000000..8956a3ec1 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomEntryTest.java @@ -0,0 +1,55 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link AtomEntry}, in particular the link lookup helper the + * dispatcher servlet relies on to find the edit URI. + */ +public class AtomEntryTest { + + @Test + public void testGetLinkHrefFindsMatchingRel() { + AtomEntry entry = new AtomEntry(); + entry.getLinks().add(new AtomLink("alternate", "http://example.com/blog/1")); + entry.getLinks().add(new AtomLink("edit", "http://example.com/app/blog/entry/1")); + + assertEquals("http://example.com/blog/1", entry.getLinkHref("alternate")); + assertEquals("http://example.com/app/blog/entry/1", entry.getLinkHref("edit")); + } + + @Test + public void testGetLinkHrefReturnsNullWhenMissing() { + AtomEntry entry = new AtomEntry(); + entry.getLinks().add(new AtomLink("alternate", "http://example.com/blog/1")); + assertNull(entry.getLinkHref("edit")); + } + + @Test + public void testGetLinkHrefReturnsFirstMatch() { + AtomEntry entry = new AtomEntry(); + entry.getLinks().add(new AtomLink("edit", "first")); + entry.getLinks().add(new AtomLink("edit", "second")); + assertEquals("first", entry.getLinkHref("edit")); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomExceptionTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomExceptionTest.java new file mode 100644 index 000000000..8219cb3a6 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomExceptionTest.java @@ -0,0 +1,59 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import javax.servlet.http.HttpServletResponse; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the AtomPub exception hierarchy and the HTTP status codes the + * dispatcher servlet maps from them. + */ +public class AtomExceptionTest { + + @Test + public void testBaseExceptionDefaultsToServerError() { + AtomException ex = new AtomException("boom"); + assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getStatus()); + assertEquals("boom", ex.getMessage()); + } + + @Test + public void testBaseExceptionPreservesCause() { + Throwable cause = new IllegalStateException("root"); + AtomException ex = new AtomException("boom", cause); + assertSame(cause, ex.getCause()); + assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getStatus()); + } + + @Test + public void testNotFoundIs404() { + assertEquals(HttpServletResponse.SC_NOT_FOUND, + new AtomNotFoundException("missing").getStatus()); + } + + @Test + public void testNotAuthorizedIs401() { + assertEquals(HttpServletResponse.SC_UNAUTHORIZED, + new AtomNotAuthorizedException("nope").getStatus()); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java new file mode 100644 index 000000000..e5f4ce6a0 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomReaderTest.java @@ -0,0 +1,181 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +import javax.servlet.http.HttpServletResponse; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for the StAX-based {@link AtomReader}. + */ +public class AtomReaderTest { + + private AtomEntry parse(String xml) throws AtomException { + return new AtomReader().parseEntry( + new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + } + + @Test + public void testParseFullEntry() throws Exception { + String xml = + "" + + "" + + " 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)); + } + + @Test + public void testDoctypeIsRefusedAsBadRequest() { + String xml = "" + + "t"; + AtomException ex = assertThrows(AtomException.class, () -> parse(xml)); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, ex.getStatus()); + } + + @Test + public void testMalformedEntryIsBadRequest() { + AtomException ex = assertThrows(AtomException.class, () -> parse("</entry>")); + assertEquals(HttpServletResponse.SC_BAD_REQUEST, ex.getStatus()); + } + + @Test + public void testSourceMetadataIsIgnored() throws Exception { + String xml = + "<entry xmlns='http://www.w3.org/2005/Atom'>" + + "<source><title>Other Blog2020-01-01T00:00:00Z" + + "Mine" + + ""; + AtomEntry entry = parse(xml); + assertEquals("Mine", entry.getTitle()); + assertNull(entry.getUpdated()); + } + + @Test + public void testXhtmlContentAndTitle() throws Exception { + String xml = + "" + + "<div xmlns='http://www.w3.org/1999/xhtml'>A <em>title</em></div>" + + "" + + " " + + "One & twothree" + + "" + + "" + + ""; + AtomEntry entry = parse(xml); + assertEquals("A title", entry.getTitle()); + assertEquals("xhtml", entry.getContent().getType()); + assertEquals("

One & two
three

", entry.getContent().getValue()); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequestTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequestTest.java new file mode 100644 index 000000000..75181fba6 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomRequestTest.java @@ -0,0 +1,97 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import javax.servlet.http.HttpServletRequest; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +/** + * Unit tests for {@link AtomRequest}, the lightweight request wrapper that + * buffers the body and normalizes a null pathInfo to "". + */ +public class AtomRequestTest { + + @Mock + private HttpServletRequest request; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + public void testNullPathInfoBecomesEmptyString() { + when(request.getPathInfo()).thenReturn(null); + assertEquals("", new AtomRequest(request, null).getPathInfo()); + } + + @Test + public void testPathInfoPassesThrough() { + when(request.getPathInfo()).thenReturn("/blog/entries"); + assertEquals("/blog/entries", new AtomRequest(request, null).getPathInfo()); + } + + @Test + public void testHeaderAndContentTypeDelegate() { + when(request.getHeader("Slug")).thenReturn("my-slug"); + when(request.getContentType()).thenReturn("application/atom+xml"); + AtomRequest areq = new AtomRequest(request, null); + assertEquals("my-slug", areq.getHeader("Slug")); + assertEquals("application/atom+xml", areq.getContentType()); + } + + @Test + public void testInputStreamReturnsBufferedBody() throws Exception { + byte[] body = "hello body".getBytes(StandardCharsets.UTF_8); + AtomRequest areq = new AtomRequest(request, body); + try (InputStream in = areq.getInputStream()) { + assertArrayEquals(body, in.readAllBytes()); + } + } + + @Test + public void testInputStreamIsFreshEachCall() throws Exception { + byte[] body = "abc".getBytes(StandardCharsets.UTF_8); + AtomRequest areq = new AtomRequest(request, body); + try (InputStream first = areq.getInputStream(); + InputStream second = areq.getInputStream()) { + assertArrayEquals(body, first.readAllBytes()); + // second stream is independent and still readable from the start + assertArrayEquals(body, second.readAllBytes()); + } + } + + @Test + public void testNullBodyYieldsEmptyStream() throws Exception { + AtomRequest areq = new AtomRequest(request, null); + try (InputStream in = areq.getInputStream()) { + assertArrayEquals(new byte[0], in.readAllBytes()); + } + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java new file mode 100644 index 000000000..f5d6fdc26 --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomSchemaValidationTest.java @@ -0,0 +1,198 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.xml.sax.ErrorHandler; +import org.xml.sax.InputSource; +import org.xml.sax.SAXParseException; + +import com.thaiopensource.util.PropertyMapBuilder; +import com.thaiopensource.validate.ValidateProperty; +import com.thaiopensource.validate.ValidationDriver; +import com.thaiopensource.validate.rng.CompactSchemaReader; + +/** + * Validates the XML produced by {@link AtomWriter} against the official RELAX NG + * schemas from RFC 4287 (Atom) and RFC 5023 (AtomPub), using Jing. This checks + * that Roller's AtomPub wire format actually conforms to the specifications, + * not just that it is well-formed. + */ +public class AtomSchemaValidationTest { + + private static final Date PUBLISHED = Date.from(Instant.parse("2026-06-03T12:34:56Z")); + private static final Date UPDATED = Date.from(Instant.parse("2026-06-04T01:02:03Z")); + + /** Validate xml against a classpath RELAX NG Compact schema; return errors (empty == valid). */ + private List 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 000000000..36870dd8e --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/AtomWriterTest.java @@ -0,0 +1,257 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.APP_NS; +import static org.apache.roller.weblogger.webservices.atomprotocol.AtomConstants.ATOM_NS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +/** + * Unit tests for the StAX-based {@link AtomWriter}. The emitted XML is parsed + * back with a namespace-aware DOM parser so the structure and namespaces can be + * asserted directly. + */ +public class AtomWriterTest { + + private static final Date PUBLISHED = Date.from(Instant.parse("2026-06-03T12:34:56Z")); + private static final Date UPDATED = Date.from(Instant.parse("2026-06-04T01:02:03Z")); + + private Document parse(byte[] xml) throws Exception { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setNamespaceAware(true); + return dbf.newDocumentBuilder().parse(new ByteArrayInputStream(xml)); + } + + private String text(Document doc, String ns, String local) { + NodeList nl = doc.getElementsByTagNameNS(ns, local); + return nl.getLength() == 0 ? null : nl.item(0).getTextContent(); + } + + private Element element(Document doc, String ns, String local) { + NodeList nl = doc.getElementsByTagNameNS(ns, local); + return nl.getLength() == 0 ? null : (Element) nl.item(0); + } + + private AtomEntry sampleEntry() { + AtomEntry entry = new AtomEntry(); + entry.setId("urn:entry:1"); + entry.setTitle("Hello World"); + entry.setPublished(PUBLISHED); + entry.setUpdated(UPDATED); + entry.setEdited(UPDATED); + entry.setDraft(false); + + AtomContent content = new AtomContent(); + content.setType("html"); + content.setValue("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")); + // RFC 5023 10.2: app:edited is a direct child of atom:entry + assertEquals(doc.getDocumentElement(), element(doc, APP_NS, "edited").getParentNode()); + } + + @Test + public void testWriteEntryDraftYes() throws Exception { + AtomEntry entry = sampleEntry(); + entry.setDraft(true); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, entry); + assertEquals("yes", text(parse(out.toByteArray()), APP_NS, "draft")); + } + + @Test + public void testWriteEntryCategoriesSchemeAndTag() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, sampleEntry()); + Document doc = parse(out.toByteArray()); + + NodeList cats = doc.getElementsByTagNameNS(ATOM_NS, "category"); + assertEquals(2, cats.getLength()); + Element tech = (Element) cats.item(0); + assertEquals("tech", tech.getAttribute("term")); + assertEquals("http://example.com/cats", tech.getAttribute("scheme")); + Element java = (Element) cats.item(1); + assertEquals("java", java.getAttribute("term")); + // a tag carries no scheme attribute at all + assertFalse(java.hasAttribute("scheme")); + } + + @Test + public void testWriteEntryEditLink() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, sampleEntry()); + Document doc = parse(out.toByteArray()); + + NodeList links = doc.getElementsByTagNameNS(ATOM_NS, "link"); + assertEquals(2, links.getLength()); + boolean foundEdit = false; + for (int i = 0; i < links.getLength(); i++) { + Element link = (Element) links.item(i); + if ("edit".equals(link.getAttribute("rel"))) { + assertEquals("http://example.com/app/blog/entry/1", link.getAttribute("href")); + foundEdit = true; + } + } + assertTrue(foundEdit); + } + + @Test + public void testWriteFeed() throws Exception { + AtomFeed feed = new AtomFeed(); + feed.setId("urn:feed:1"); + feed.setTitle("My Blog"); + feed.setUpdated(UPDATED); + feed.getLinks().add(new AtomLink("alternate", "http://example.com/blog")); + feed.getEntries().add(sampleEntry()); + feed.getEntries().add(sampleEntry()); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeFeed(out, feed); + Document doc = parse(out.toByteArray()); + + assertEquals("feed", doc.getDocumentElement().getLocalName()); + assertEquals(ATOM_NS, doc.getDocumentElement().getNamespaceURI()); + assertEquals("My Blog", text(doc, ATOM_NS, "title")); + assertEquals(2, doc.getElementsByTagNameNS(ATOM_NS, "entry").getLength()); + } + + @Test + public void testWriteServiceDoc() throws Exception { + AtomServiceDoc service = new AtomServiceDoc(); + AtomWorkspace workspace = new AtomWorkspace(); + workspace.setTitle("My Weblog"); + service.getWorkspaces().add(workspace); + + AtomCollection collection = new AtomCollection(); + collection.setTitle("Weblog Entries"); + collection.setHref("http://example.com/app/blog/entries"); + collection.setAccepts(Arrays.asList("application/atom+xml;type=entry")); + AtomCategories cats = new AtomCategories(); + cats.setFixed(true); + cats.setScheme("http://example.com/cats"); + AtomCategory cat = new AtomCategory(); + cat.setTerm("tech"); + cat.setLabel("tech"); + cats.getCategories().add(cat); + collection.getCategories().add(cats); + workspace.getCollections().add(collection); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeServiceDoc(out, service); + Document doc = parse(out.toByteArray()); + + assertEquals("service", doc.getDocumentElement().getLocalName()); + assertEquals(APP_NS, doc.getDocumentElement().getNamespaceURI()); + assertNotNull(element(doc, APP_NS, "workspace")); + + Element coll = element(doc, APP_NS, "collection"); + assertEquals("http://example.com/app/blog/entries", coll.getAttribute("href")); + // titles are atom:title (Atom namespace) even inside the service doc + assertEquals("My Weblog", text(doc, ATOM_NS, "title")); + assertEquals("application/atom+xml;type=entry", text(doc, APP_NS, "accept")); + + Element categories = element(doc, APP_NS, "categories"); + assertEquals("yes", categories.getAttribute("fixed")); + assertEquals("http://example.com/cats", categories.getAttribute("scheme")); + assertEquals("tech", element(doc, ATOM_NS, "category").getAttribute("term")); + } + + @Test + public void testEntryRoundTripThroughReader() throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + new AtomWriter().writeEntry(out, sampleEntry()); + + AtomEntry parsed = new AtomReader().parseEntry(new ByteArrayInputStream(out.toByteArray())); + + assertEquals("urn:entry:1", parsed.getId()); + assertEquals("Hello World", parsed.getTitle()); + assertEquals("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/MediaCollectionPathTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java index dd145fdb4..b8d956263 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionPathTest.java @@ -18,20 +18,14 @@ import java.io.ByteArrayInputStream; import java.util.Collections; -import java.util.List; - -import com.rometools.propono.atom.server.AtomException; -import com.rometools.propono.atom.server.AtomNotFoundException; -import com.rometools.propono.atom.server.AtomRequest; -import com.rometools.rome.feed.atom.Content; -import com.rometools.rome.feed.atom.Entry; -import com.rometools.rome.feed.atom.Feed; + import org.apache.roller.weblogger.business.FileContentManager; import org.apache.roller.weblogger.business.MediaFileManager; import org.apache.roller.weblogger.business.WeblogManager; import org.apache.roller.weblogger.business.Weblogger; import org.apache.roller.weblogger.business.WebloggerFactory; import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; +import org.apache.roller.weblogger.pojos.MediaFile; import org.apache.roller.weblogger.pojos.MediaFileDirectory; import org.apache.roller.weblogger.pojos.User; import org.apache.roller.weblogger.pojos.Weblog; @@ -104,7 +98,7 @@ void tearDown() { @Test void namedCollectionIsLookedUpByItsName() throws Exception { - Feed feed = collection.getCollection(request("/blog/resources/default")); + AtomFeed feed = collection.getCollection(request("/blog/resources/default")); assertTrue(feed.getEntries().isEmpty()); verify(files).getMediaFileDirectoryByName(weblog, "default"); @@ -146,6 +140,50 @@ void mediaWithoutSlugOrTitleIsNamedByDate() throws Exception { verify(files, never()).createMediaFile(any(), any(), any()); } + @Test + void deleteThroughTheEditUriRemovesTheMediaFile() throws Exception { + MediaFile file = mock(MediaFile.class); + when(files.getMediaFileByPath(weblog, "default/a.png")).thenReturn(file); + + collection.deleteEntry(request("/blog/resource/default/a.png.media-link")); + + verify(files).removeMediaFile(weblog, file); + } + + @Test + void deleteOfAnUnknownFileIsNotFound() throws Exception { + assertThrows(AtomNotFoundException.class, + () -> collection.deleteEntry(request("/blog/resource/default/missing.png.media-link"))); + verify(files, never()).removeMediaFile(any(), any()); + } + + @Test + void mediaLinkEntryOfAnUnknownWeblogIsNotFound() { + assertThrows(AtomNotFoundException.class, + () -> collection.getEntry(request("/nosuchblog/resource/default/a.png.media-link"))); + } + + @Test + void mediaLinkEntryNeedsPermissionOnTheWeblog() throws Exception { + when(weblog.hasUserPermission(any(), eq(WeblogPermission.POST))).thenReturn(false); + + assertThrows(AtomNotAuthorizedException.class, + () -> collection.getEntry(request("/blog/resource/default/a.png.media-link"))); + verify(files, never()).getMediaFileByPath(any(), anyString()); + } + + @Test + void getOfAnUnknownMediaResourceIsNotFound() { + assertThrows(AtomNotFoundException.class, + () -> collection.getMediaResource(request("/blog/resource/default/missing.png"))); + } + + @Test + void putOfAnUnknownMediaResourceIsNotFound() { + assertThrows(AtomNotFoundException.class, + () -> collection.putMedia(request("/blog/resource/default/missing.png"))); + } + private void refuseUploads() throws Exception { when(content.canSave(any(), anyString(), anyString(), anyLong(), any())) .thenAnswer(call -> { @@ -161,12 +199,12 @@ private static AtomRequest request(String pathInfo) throws Exception { return request; } - private static Entry mediaEntry(String title) { - Content body = new Content(); + private static AtomEntry mediaEntry(String title) { + AtomContent body = new AtomContent(); body.setType("image/png"); - Entry entry = new Entry(); + AtomEntry entry = new AtomEntry(); entry.setTitle(title); - entry.setContents(List.of(body)); + entry.setContent(body); return entry; } } diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionTest.java index 5e06d78db..868bd85d3 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/MediaCollectionTest.java @@ -22,7 +22,6 @@ import javax.servlet.http.HttpServletResponse; -import com.rometools.propono.atom.server.AtomMediaResource; import org.apache.roller.weblogger.pojos.MediaFile; import org.apache.roller.weblogger.util.MediaTypePolicy; import org.junit.jupiter.api.Test; diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java new file mode 100644 index 000000000..0e791e43b --- /dev/null +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomProtocolTest.java @@ -0,0 +1,258 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one or more +* contributor license agreements. The ASF licenses this file to You +* under the Apache License, Version 2.0 (the "License"); you may not +* use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. For additional information regarding +* copyright in this work, please see the NOTICE file in the top level +* directory of this distribution. +*/ +package org.apache.roller.weblogger.webservices.atomprotocol; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; + +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; + +import org.apache.roller.weblogger.TestUtils; +import org.apache.roller.weblogger.business.MediaFileManager; +import org.apache.roller.weblogger.business.PropertiesManager; +import org.apache.roller.weblogger.business.WeblogEntryManager; +import org.apache.roller.weblogger.business.WebloggerFactory; +import org.apache.roller.weblogger.pojos.MediaFile; +import org.apache.roller.weblogger.pojos.MediaFileDirectory; +import org.apache.roller.weblogger.pojos.RuntimeConfigProperty; +import org.apache.roller.weblogger.pojos.User; +import org.apache.roller.weblogger.pojos.Weblog; +import org.apache.roller.weblogger.pojos.WeblogEntry; +import org.apache.roller.weblogger.pojos.WeblogEntry.PubStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.apache.roller.weblogger.ui.core.RollerContext; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +/** + * Integration tests that exercise the AtomPub server handlers against a real + * (in-memory Derby) Roller, driving the full create / retrieve / update / delete + * lifecycle through {@link EntryCollection}, {@link MediaCollection} and + * {@link RollerAtomService}. The HTTP transport and BASIC authentication are not + * exercised here (they require the Spring web context); those are covered by an + * over-the-wire exerciser such as APE run against a deployed instance. + */ +public class RollerAtomProtocolTest { + + private static final String HANDLE = "atomtestblog"; + private static final String ATOM_URL = "http://localhost/roller/roller-services/app"; + + private User testUser; + private Weblog testWeblog; + + @BeforeEach + public void setUp() throws Exception { + TestUtils.setupWeblogger(); + testUser = TestUtils.setupUser("atomtestuser"); + testWeblog = TestUtils.setupWeblog(HANDLE, testUser); + + // RollerAtomService requires AtomPub to be enabled; media tests need uploads enabled + PropertiesManager pmgr = WebloggerFactory.getWeblogger().getPropertiesManager(); + RuntimeConfigProperty enableAtomPub = pmgr.getProperty("webservices.enableAtomPub"); + enableAtomPub.setValue("true"); + pmgr.saveProperty(enableAtomPub); + RuntimeConfigProperty enableUploads = pmgr.getProperty("uploads.enabled"); + enableUploads.setValue("true"); + pmgr.saveProperty(enableUploads); + + TestUtils.endSession(true); + } + + @AfterEach + public void tearDown() throws Exception { + TestUtils.teardownWeblog(testWeblog.getId()); + TestUtils.teardownUser(testUser.getUserName()); + TestUtils.endSession(true); + } + + private User managedUser() throws Exception { + return TestUtils.getManagedUser(testUser); + } + + /** Build an AtomRequest backed by a mocked HttpServletRequest. */ + private AtomRequest request(String pathInfo, String contentType, String slug, byte[] body) { + HttpServletRequest req = mock(HttpServletRequest.class); + lenient().when(req.getPathInfo()).thenReturn(pathInfo); + lenient().when(req.getContentType()).thenReturn(contentType); + lenient().when(req.getHeader("Slug")).thenReturn(slug); + return new AtomRequest(req, body); + } + + private AtomEntry sampleEntry(String title, String body, boolean draft) { + AtomEntry entry = new AtomEntry(); + entry.setTitle(title); + AtomContent content = new AtomContent(); + content.setType("html"); + content.setValue(body); + entry.setContent(content); + entry.setDraft(draft); + // a free-form tag (no scheme); the weblog category will default + AtomCategory tag = new AtomCategory(); + tag.setTerm("testing"); + entry.getCategories().add(tag); + return entry; + } + + private String entryIdFromEditLink(AtomEntry entry) { + String edit = entry.getLinkHref("edit"); + assertNotNull(edit, "created entry must have an edit link"); + return edit.substring(edit.lastIndexOf("/entry/") + "/entry/".length()); + } + + @Test + public void testEntryLifecycle() throws Exception { + // ---- create ---- + EntryCollection ecol = new EntryCollection(managedUser(), ATOM_URL); + AtomEntry created = ecol.postEntry( + request("/" + HANDLE + "/entries", "application/atom+xml", null, null), + sampleEntry("First Post", "

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"); + + // MediaCollection derives the stored type through the servlet context + ServletContext servletContext = mock(ServletContext.class); + when(servletContext.getMimeType(anyString())).thenReturn("image/png"); + AtomEntry created; + try (MockedStatic rollerContext = + mockStatic(RollerContext.class, CALLS_REAL_METHODS)) { + rollerContext.when(RollerContext::getServletContext).thenReturn(servletContext); + created = mcol.postMedia( + request("/" + HANDLE + "/resources/atomuploads", "image/png", "snapshot", bytes), + mediaIn); + } + + assertNotNull(created.getLinkHref("edit")); + assertNotNull(created.getLinkHref("edit-media")); + String fileName = created.getTitle(); + TestUtils.endSession(true); + + // ---- it persisted into the named media directory ---- + mfm = WebloggerFactory.getWeblogger().getMediaFileManager(); + weblog = WebloggerFactory.getWeblogger().getWeblogManager().getWeblogByHandle(HANDLE); + MediaFileDirectory dir = mfm.getMediaFileDirectoryByName(weblog, "atomuploads"); + assertNotNull(dir, "named upload directory should exist"); + MediaFile stored = dir.getMediaFiles().stream() + .filter(mf -> mf.getName().equals(fileName)) + .findFirst().orElse(null); + assertNotNull(stored, "uploaded media file should be persisted"); + assertEquals("image/png", stored.getContentType()); + } +} diff --git a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java index 106fdcfe4..c603b6041 100644 --- a/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java +++ b/app/src/test/java/org/apache/roller/weblogger/webservices/atomprotocol/RollerAtomServletTest.java @@ -19,22 +19,20 @@ package org.apache.roller.weblogger.webservices.atomprotocol; import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.charset.StandardCharsets; import java.util.Arrays; -import javax.xml.parsers.ParserConfigurationException; -import javax.xml.parsers.SAXParserFactory; import javax.servlet.ReadListener; -import javax.servlet.ServletException; import javax.servlet.ServletInputStream; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import com.rometools.propono.atom.server.AtomHandler; import org.apache.roller.weblogger.config.WebloggerRuntimeConfig; -import org.apache.roller.weblogger.util.SecureXmlParsers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -42,14 +40,17 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -72,7 +73,6 @@ class RollerAtomServletTest { private MockedStatic config; private RecordingServlet servlet; private HttpServletResponse response; - private StringWriter responseBody; @BeforeEach void setUp() throws IOException { @@ -80,9 +80,7 @@ void setUp() throws IOException { config.when(() -> WebloggerRuntimeConfig.getBooleanProperty("webservices.enableAtomPub")) .thenReturn(true); servlet = new RecordingServlet(); - response = mock(HttpServletResponse.class); - responseBody = new StringWriter(); - when(response.getWriter()).thenReturn(new PrintWriter(responseBody)); + response = response(); } @AfterEach @@ -100,31 +98,28 @@ void disabledServiceAnswersNotFoundForWritesAndReads() throws Exception { verify(response).setStatus(HttpServletResponse.SC_NOT_FOUND); verify(request, never()).getInputStream(); - assertNull(servlet.forwarded); + assertNull(servlet.handler); - HttpServletResponse readResponse = mock(HttpServletResponse.class); - when(readResponse.getWriter()).thenReturn(new PrintWriter(new StringWriter())); + HttpServletResponse readResponse = response(); servlet.service(request("GET", "/blog/entries", null, ""), readResponse); verify(readResponse).setStatus(HttpServletResponse.SC_NOT_FOUND); - assertNull(servlet.forwarded); + assertNull(servlet.handler); } @Test - void readsPassThroughUnchanged() throws Exception { - HttpServletRequest request = request("GET", "/blog/entries", null, ""); - - servlet.service(request, response); + void readsAreDispatchedToTheHandler() throws Exception { + servlet.service(request("GET", "/blog/entries", null, ""), response); - assertSame(request, servlet.forwarded); + verify(servlet.handler).getCollection(any()); } @Test - void wellFormedEntryIsForwardedWithTheSameBody() throws Exception { + void wellFormedEntryIsPostedWithTheSameBody() throws Exception { servlet.service(request("POST", "/blog/entries", "application/atom+xml;type=entry", ENTRY), response); - assertNotNull(servlet.forwarded); - assertArrayEquals(ENTRY.getBytes(StandardCharsets.UTF_8), servlet.forwardedBody); + assertEquals("Hello", servlet.postedEntry.getTitle()); + assertArrayEquals(ENTRY.getBytes(StandardCharsets.UTF_8), servlet.body); } @Test @@ -132,7 +127,7 @@ void postedEntryWithDoctypeIsRefused() throws Exception { servlet.service(request("POST", "/blog/entries", "application/atom+xml", ENTRY_WITH_DOCTYPE), response); verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST); - assertNull(servlet.forwarded); + assertNull(servlet.body); } @Test @@ -140,24 +135,37 @@ void entryUpdateWithDoctypeIsRefusedWhateverItsContentType() throws Exception { servlet.service(request("PUT", "/blog/entry/abc", "text/plain", ENTRY_WITH_DOCTYPE), response); verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST); - assertNull(servlet.forwarded); + assertNull(servlet.body); } @Test void mediaUploadsBypassTheEntryLimit() throws Exception { - mediaUploadIsForwardedWithoutApplyingTheEntryLimit("POST", "/blog/resources"); - mediaUploadIsForwardedWithoutApplyingTheEntryLimit("PUT", "/blog/resources/image.png"); + mediaUploadBypassesTheEntryLimit("POST", "/blog/resources"); + servlet.body = null; + mediaUploadBypassesTheEntryLimit("PUT", "/blog/resource/image.png"); } - private void mediaUploadIsForwardedWithoutApplyingTheEntryLimit(String method, String path) throws Exception { + private void mediaUploadBypassesTheEntryLimit(String method, String path) throws Exception { config.when(() -> WebloggerRuntimeConfig.getProperty(RollerAtomServlet.MAX_ENTRY_SIZE_PROPERTY)) .thenReturn("1"); HttpServletRequest request = request(method, path, "image/png", "not xml"); servlet.service(request, response); - assertSame(request, servlet.forwarded); + assertArrayEquals("not xml".getBytes(StandardCharsets.UTF_8), servlet.body); + // The servlet does not buffer media; the handler reads the request stream + verify(request, times(1)).getInputStream(); + } + + @Test + void postWithoutContentTypeIsUnsupportedMediaType() throws Exception { + HttpServletRequest request = request("POST", "/blog/resources", null, "data"); + + servlet.service(request, response); + + verify(response).setStatus(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE); verify(request, never()).getInputStream(); + assertNull(servlet.body); } @Test @@ -171,7 +179,7 @@ void oversizedEntryIsRefused() throws Exception { servlet.service(request, response); verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); - assertNull(servlet.forwarded); + assertNull(servlet.body); assertEquals(99, input.available(), "Read only one byte past the entry limit"); } @@ -182,14 +190,13 @@ void entryExactlyAtTheDefaultLimitIsAccepted() throws Exception { servlet.service(request("POST", "/blog/entries", "application/atom+xml", body), response); - assertNotNull(servlet.forwarded); - assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.forwardedBody); + assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.body); } @Test void configuredLimitAppliesToEntryPostsAndUpdates() throws Exception { configuredLimitCountsUtf8BytesAndChangesOnTheNextRequest("POST", "/blog/entries"); - servlet.forwarded = null; + servlet.body = null; clearInvocations(response); configuredLimitCountsUtf8BytesAndChangesOnTheNextRequest("PUT", "/blog/entry/abc"); } @@ -203,12 +210,11 @@ private void configuredLimitCountsUtf8BytesAndChangesOnTheNextRequest(String met servlet.service(request(method, path, "application/atom+xml", body), response); verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); - assertNull(servlet.forwarded); + assertNull(servlet.body); servlet.service(request(method, path, "application/atom+xml", body), response); - assertNotNull(servlet.forwarded); - assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.forwardedBody); + assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.body); } @Test @@ -225,9 +231,8 @@ private void missingOrInvalidLimitUsesTheDefault(String value) throws Exception String body = ENTRY + " ".repeat( RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES - ENTRY.getBytes(StandardCharsets.UTF_8).length); servlet.service(request("POST", "/blog/entries", "application/atom+xml", body), response); - assertNotNull(servlet.forwarded); - assertEquals(RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES, servlet.forwardedBody.length); - servlet.forwarded = null; + assertEquals(RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES, servlet.body.length); + servlet.body = null; byte[] big = new byte[RollerAtomServlet.DEFAULT_MAX_ENTRY_BYTES + 1]; HttpServletRequest request = request("POST", "/blog/entries", "application/atom+xml", ""); when(request.getInputStream()).thenReturn(stream(big)); @@ -235,7 +240,7 @@ private void missingOrInvalidLimitUsesTheDefault(String value) throws Exception servlet.service(request, response); verify(response).setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE); - assertNull(servlet.forwarded); + assertNull(servlet.body); } @Test @@ -246,7 +251,7 @@ void configuredLimitCanBeRaisedAboveTheDefault() throws Exception { servlet.service(request("POST", "/blog/entries", "application/atom+xml", body), response); - assertNotNull(servlet.forwarded); + assertArrayEquals(body.getBytes(StandardCharsets.UTF_8), servlet.body); } @Test @@ -260,22 +265,7 @@ private void malformedEntryIsRefused(String method, String path) throws Exceptio servlet.service(request(method, path, "application/atom+xml", "</entry>"), response); verify(response).setStatus(HttpServletResponse.SC_BAD_REQUEST); - assertNull(servlet.forwarded); - } - - @Test - void parserSetupFailureIsAServerError() throws Exception { - try (MockedStatic<SecureXmlParsers> parsers = mockStatic(SecureXmlParsers.class)) { - SAXParserFactory factory = mock(SAXParserFactory.class); - parsers.when(SecureXmlParsers::newSAXParserFactory).thenReturn(factory); - when(factory.newSAXParser()).thenThrow(new ParserConfigurationException("Cannot create parser")); - - assertThrows(ServletException.class, () -> servlet.service( - request("POST", "/blog/entries", "application/atom+xml", ENTRY), response)); - - verify(response, never()).setStatus(HttpServletResponse.SC_BAD_REQUEST); - assertNull(servlet.forwarded); - } + assertNull(servlet.body); } @Test @@ -293,19 +283,78 @@ private void unauthenticatedEntryIsRefusedWithoutReadingTheBody(String method, S verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED); verify(request, never()).getInputStream(); - assertNull(servlet.forwarded); + assertNull(servlet.body); } @Test - void authenticatedHandlerIsReusedByTheFactory() throws Exception { - HttpServletRequest request = request("POST", "/blog/entries", "application/atom+xml", ENTRY); + void serverErrorsDoNotExposeTheExceptionMessage() throws Exception { + servlet.failure = new IllegalStateException("secret internal detail"); - servlet.service(request, response); + servlet.service(request("GET", "/blog/entries", null, ""), response); + + verify(response).sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + verify(response, never()).sendError(anyInt(), anyString()); + } + + @Test + void clientErrorsKeepTheirMessage() throws Exception { + servlet.service(request("GET", "/blog/nowhere", null, ""), response); + + verify(response).sendError(HttpServletResponse.SC_NOT_FOUND, "Cannot find specified resource"); + } - verify(request).setAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE, servlet.handler); - when(request.getAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE)).thenReturn(servlet.handler); - assertSame(servlet.handler, new RollerAtomHandlerFactory().newAtomHandler(request, response)); - verify(request).removeAttribute(RollerAtomServlet.HANDLER_ATTRIBUTE); + @Test + void locationMustBeAValidUriUnderTheAtomUrl() { + String atom = "https://blog.example/roller-services/app"; + assertEquals(atom + "/blog/entry/1", RollerAtomServlet.safeLocation(atom + "/blog/entry/1", atom)); + assertEquals(atom + "/blog/resource/caf%C3%A9.png.media-link", + RollerAtomServlet.safeLocation(atom + "/blog/resource/café.png.media-link", atom)); + assertNull(RollerAtomServlet.safeLocation("https://evil.example/x", atom)); + assertNull(RollerAtomServlet.safeLocation(atom + ".evil.example/x", atom)); + assertNull(RollerAtomServlet.safeLocation(atom + "/blog/a\r\nSet-Cookie: x=y", atom)); + assertNull(RollerAtomServlet.safeLocation(atom + "/blog/entry/1", null)); + assertNull(RollerAtomServlet.safeLocation(null, atom)); + } + + @Test + void createdEntryLocationIsSetOnlyWhenSafe() throws Exception { + servlet.atomURL = "https://blog.example/roller-services/app"; + servlet.editHref = servlet.atomURL + "/blog/entry/1"; + + servlet.service(request("POST", "/blog/entries", "application/atom+xml", ENTRY), response); + + verify(response).setStatus(HttpServletResponse.SC_CREATED); + verify(response).setHeader("Location", servlet.editHref); + + HttpServletResponse unsafe = response(); + servlet.editHref = "https://evil.example/x"; + servlet.service(request("POST", "/blog/entries", "application/atom+xml", ENTRY), unsafe); + + verify(unsafe).setStatus(HttpServletResponse.SC_CREATED); + verify(unsafe, never()).setHeader(eq("Location"), anyString()); + } + + private static HttpServletResponse response() throws IOException { + HttpServletResponse response = mock(HttpServletResponse.class); + when(response.getWriter()).thenReturn(new PrintWriter(new StringWriter())); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + when(response.getOutputStream()).thenReturn(new ServletOutputStream() { + @Override + public void write(int b) { + out.write(b); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setWriteListener(WriteListener listener) { + throw new UnsupportedOperationException(); + } + }); + return response; } private static HttpServletRequest request(String method, String pathInfo, @@ -353,27 +402,66 @@ public void setReadListener(ReadListener listener) { }; } - /** Records what would have been handed to the Propono servlet. */ + /** Records the request body and entry that reach the handler. */ private static final class RecordingServlet extends RollerAtomServlet { private static final long serialVersionUID = 1L; - HttpServletRequest forwarded; - byte[] forwardedBody; String userName = "alice"; - AtomHandler handler; + String atomURL; + String editHref; + RuntimeException failure; + RollerAtomHandler handler; + AtomEntry postedEntry; + byte[] body; @Override - protected AtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) { - handler = mock(AtomHandler.class); - when(handler.getAuthenticatedUsername()).thenReturn(userName); + protected RollerAtomHandler createHandler(HttpServletRequest req, HttpServletResponse res) { + handler = mock(RollerAtomHandler.class); + try { + when(handler.getAuthenticatedUsername()).thenReturn(userName); + when(handler.getAtomURL()).thenReturn(atomURL); + when(handler.isCollectionURI(any())).thenAnswer(call -> + call.<AtomRequest>getArgument(0).getPathInfo().matches("/[^/]+/(entries|resources).*")); + when(handler.isEntryURI(any())).thenAnswer(call -> + RollerAtomHandler.isEntryPath(call.<AtomRequest>getArgument(0).getPathInfo())); + when(handler.isMediaEditURI(any())).thenAnswer(call -> + call.<AtomRequest>getArgument(0).getPathInfo().matches("/[^/]+/resource/.*")); + if (failure != null) { + when(handler.getCollection(any())).thenThrow(failure); + } else { + when(handler.getCollection(any())).thenReturn(new AtomFeed()); + } + when(handler.postEntry(any(), any())).thenAnswer(call -> { + record(call.getArgument(0)); + postedEntry = call.getArgument(1); + AtomEntry created = new AtomEntry(); + if (editHref != null) { + AtomLink edit = new AtomLink(); + edit.setRel("edit"); + edit.setHref(editHref); + created.getLinks().add(edit); + } + return created; + }); + when(handler.postMedia(any(), any())).thenAnswer(call -> { + record(call.getArgument(0)); + return new AtomEntry(); + }); + doAnswer(call -> { + record(call.getArgument(0)); + return null; + }).when(handler).putEntry(any(), any()); + doAnswer(call -> { + record(call.getArgument(0)); + return null; + }).when(handler).putMedia(any()); + } catch (AtomException e) { + throw new IllegalStateException(e); + } return handler; } - @Override - protected void forward(HttpServletRequest req, HttpServletResponse res) throws IOException { - forwarded = req; - if (req instanceof BufferedBodyRequest) { - forwardedBody = req.getInputStream().readAllBytes(); - } + private void record(AtomRequest areq) throws IOException { + body = areq.getInputStream().readAllBytes(); } } } diff --git a/app/src/test/resources/atompub/app-service.rnc b/app/src/test/resources/atompub/app-service.rnc new file mode 100644 index 000000000..05d45008f --- /dev/null +++ b/app/src/test/resources/atompub/app-service.rnc @@ -0,0 +1,192 @@ +# Copied from RFC 5023, "The Atom Publishing Protocol", Appendix B +# (https://www.rfc-editor.org/rfc/rfc5023#appendix-B). Used only by tests; +# not included in the Roller web application. +# +# Copyright (C) The IETF Trust (2007). +# +# This document is subject to the rights, licenses and restrictions contained +# in BCP 78, and except as set forth therein, the authors retain all their +# rights. +# +# RELAX NG Compact Syntax Grammar for the Atom Publishing Protocol service +# document (RFC 5023, Appendix B, the start = appService grammar). Used by +# AtomSchemaValidationTest to verify that the service documents produced by +# AtomWriter conform to RFC 5023. + +namespace app = "http://www.w3.org/2007/app" +namespace atom = "http://www.w3.org/2005/Atom" +namespace xsd = "http://www.w3.org/2001/XMLSchema" +namespace xhtml = "http://www.w3.org/1999/xhtml" +namespace local = "" + +start = appService + +# common:attrs + +atomURI = text + +appCommonAttributes = + attribute xml:base { atomURI }?, + attribute xml:lang { atomLanguageTag }?, + attribute xml:space {"default"|"preserved"}?, + undefinedAttribute* + +atomCommonAttributes = appCommonAttributes + +undefinedAttribute = attribute * - (xml:base | xml:space | xml:lang + | local:*) { text } + +atomLanguageTag = xsd:string { + pattern = "([A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*)?" +} + +atomDateConstruct = + appCommonAttributes, + xsd:dateTime + +# app:service +appService = + element app:service { + appCommonAttributes, + ( appWorkspace+ + & extensionElement* ) + } + +# app:workspace + +appWorkspace = + element app:workspace { + appCommonAttributes, + ( atomTitle + & appCollection* + & extensionSansTitleElement* ) + } + +atomTitle = element atom:title { atomTextConstruct } + +# app:collection + +appCollection = + element app:collection { + appCommonAttributes, + attribute href { atomURI }, + ( atomTitle + & appAccept* + & appCategories* + & extensionSansTitleElement* ) + } + +# app:categories + +atomCategory = + element atom:category { + atomCommonAttributes, + attribute term { text }, + attribute scheme { atomURI }?, + attribute label { text }?, + undefinedContent + } + +appInlineCategories = + element app:categories { + attribute fixed { "yes" | "no" }?, + attribute scheme { atomURI }?, + (atomCategory*, + undefinedContent) + } + +appOutOfLineCategories = + element app:categories { + attribute href { atomURI }, + undefinedContent + } + +appCategories = appInlineCategories | appOutOfLineCategories + +# app:accept + +appAccept = + element app:accept { + appCommonAttributes, + ( text? ) + } + +# Simple Extension + +simpleSansTitleExtensionElement = + element * - (app:*|atom:title) { + text + } + +simpleExtensionElement = + element * - (app:*) { + text + } + +# Structured Extension + +structuredSansTitleExtensionElement = + element * - (app:*|atom:title) { + (attribute * { text }+, + (text|anyElement)*) + | (attribute * { text }*, + (text?, anyElement+, (text|anyElement)*)) + } + +structuredExtensionElement = + element * - (app:*) { + (attribute * { text }+, + (text|anyElement)*) + | (attribute * { text }*, + (text?, anyElement+, (text|anyElement)*)) + } + +# Other Extensibility + +extensionSansTitleElement = + simpleSansTitleExtensionElement|structuredSansTitleExtensionElement + +extensionElement = simpleExtensionElement | + structuredExtensionElement + +undefinedContent = (text|anyForeignElement)* + +# Extensions + +anyElement = + element * { + (attribute * { text } + | text + | anyElement)* + } + +anyForeignElement = + element * - app:* { + (attribute * { text } + | text + | anyElement)* + } + +atomPlainTextConstruct = + atomCommonAttributes, + attribute type { "text" | "html" }?, + text + +atomXHTMLTextConstruct = + atomCommonAttributes, + attribute type { "xhtml" }, + xhtmlDiv + +atomTextConstruct = atomPlainTextConstruct | atomXHTMLTextConstruct + +anyXHTML = element xhtml:* { + (attribute * { text } + | text + | anyXHTML)* +} + +xhtmlDiv = element xhtml:div { + (attribute * { text } + | text + | anyXHTML)* +} diff --git a/app/src/test/resources/atompub/atom.rnc b/app/src/test/resources/atompub/atom.rnc new file mode 100644 index 000000000..d51024598 --- /dev/null +++ b/app/src/test/resources/atompub/atom.rnc @@ -0,0 +1,288 @@ +# Copied from RFC 4287, "The Atom Syndication Format", Appendix B +# (https://www.rfc-editor.org/rfc/rfc4287#appendix-B). Used only by tests; +# not included in the Roller web application. +# +# Copyright (C) The Internet Society (2005). +# +# This document is subject to the rights, licenses and restrictions contained +# in BCP 78, and except as set forth therein, the authors retain all their +# rights. +# +# RELAX NG Compact Syntax Grammar for the Atom Format Specification (RFC 4287, +# Appendix B). Used by AtomSchemaValidationTest to verify that the entries and +# feeds produced by AtomWriter conform to the Atom Syndication Format. The +# embedded Schematron (s:*) annotations are ignored by Jing's RELAX NG validator. + +namespace atom = "http://www.w3.org/2005/Atom" +namespace xhtml = "http://www.w3.org/1999/xhtml" +namespace s = "http://www.ascc.net/xml/schematron" +namespace local = "" + +start = atomFeed | atomEntry + +atomCommonAttributes = + attribute xml:base { atomUri }?, + attribute xml:lang { atomLanguageTag }?, + undefinedAttribute* + +atomPlainTextConstruct = + atomCommonAttributes, + attribute type { "text" | "html" }?, + text + +atomXHTMLTextConstruct = + atomCommonAttributes, + attribute type { "xhtml" }, + xhtmlDiv + +atomTextConstruct = atomPlainTextConstruct | atomXHTMLTextConstruct + +atomPersonConstruct = + atomCommonAttributes, + (element atom:name { text } + & element atom:uri { atomUri }? + & element atom:email { atomEmailAddress }? + & extensionElement*) + +atomDateConstruct = + atomCommonAttributes, + xsd:dateTime + +atomFeed = + [ + s:rule [ + context = "atom:feed" + s:assert [ + test = "atom:author or not(atom:entry[not(atom:author)])" + "An atom:feed must have an atom:author unless all of its" + ~ "atom:entry children have an atom:author." + ] + ] + ] + element atom:feed { + atomCommonAttributes, + (atomAuthor* + & atomCategory* + & atomContributor* + & atomGenerator? + & atomIcon? + & atomId + & atomLink* + & atomLogo? + & atomRights? + & atomSubtitle? + & atomTitle + & atomUpdated + & extensionElement*), + atomEntry* + } + +atomEntry = + [ + s:rule [ + context = "atom:entry" + s:assert [ + test = "atom:link[@rel='alternate'] " + ~ "or atom:link[not(@rel)] " + ~ "or atom:content" + "An atom:entry must have at least one atom:link element " + ~ "with a rel attribute of 'alternate' " + ~ "or an atom:content." + ] + ] + s:rule [ + context = "atom:entry" + s:assert [ + test = "atom:author or " + ~ "../atom:author or atom:source/atom:author" + "An atom:entry must have an atom:author " + ~ "if its feed does not." + ] + ] + ] + element atom:entry { + atomCommonAttributes, + (atomAuthor* + & atomCategory* + & atomContent? + & atomContributor* + & atomId + & atomLink* + & atomPublished? + & atomRights? + & atomSource? + & atomSummary? + & atomTitle + & atomUpdated + & extensionElement*) + } + +atomInlineTextContent = + element atom:content { + atomCommonAttributes, + attribute type { "text" | "html" }?, + (text)* + } + +atomInlineXHTMLContent = + element atom:content { + atomCommonAttributes, + attribute type { "xhtml" }, + xhtmlDiv + } + +atomInlineOtherContent = + element atom:content { + atomCommonAttributes, + attribute type { atomMediaType }?, + (text|anyElement)* + } + +atomOutOfLineContent = + element atom:content { + atomCommonAttributes, + attribute type { atomMediaType }?, + attribute src { atomUri }, + empty + } + +atomContent = atomInlineTextContent + | atomInlineXHTMLContent + | atomInlineOtherContent + | atomOutOfLineContent + +atomAuthor = element atom:author { atomPersonConstruct } + +atomCategory = + element atom:category { + atomCommonAttributes, + attribute term { text }, + attribute scheme { atomUri }?, + attribute label { text }?, + undefinedContent + } + +atomContributor = element atom:contributor { atomPersonConstruct } + +atomGenerator = element atom:generator { + atomCommonAttributes, + attribute uri { atomUri }?, + attribute version { text }?, + text +} + +atomIcon = element atom:icon { + atomCommonAttributes, + (atomUri) +} + +atomId = element atom:id { + atomCommonAttributes, + (atomUri) +} + +atomLogo = element atom:logo { + atomCommonAttributes, + (atomUri) +} + +atomLink = + element atom:link { + atomCommonAttributes, + attribute href { atomUri }, + attribute rel { atomNCName | atomUri }?, + attribute type { atomMediaType }?, + attribute hreflang { atomLanguageTag }?, + attribute title { text }?, + attribute length { text }?, + undefinedContent + } + +atomPublished = element atom:published { atomDateConstruct } + +atomRights = element atom:rights { atomTextConstruct } + +atomSource = + element atom:source { + atomCommonAttributes, + (atomAuthor* + & atomCategory* + & atomContributor* + & atomGenerator? + & atomIcon? + & atomId? + & atomLink* + & atomLogo? + & atomRights? + & atomSubtitle? + & atomTitle? + & atomUpdated? + & extensionElement*) + } + +atomSubtitle = element atom:subtitle { atomTextConstruct } + +atomSummary = element atom:summary { atomTextConstruct } + +atomTitle = element atom:title { atomTextConstruct } + +atomUpdated = element atom:updated { atomDateConstruct } + +atomNCName = xsd:string { minLength = "1" pattern = "[^:]*" } + +atomMediaType = xsd:string { pattern = ".+/.+" } + +atomLanguageTag = xsd:string { + pattern = "[A-Za-z]{1,8}(-[A-Za-z0-9]{1,8})*" +} + +atomUri = text + +atomEmailAddress = xsd:string { pattern = ".+@.+" } + +simpleExtensionElement = + element * - atom:* { + text + } + +structuredExtensionElement = + element * - atom:* { + (attribute * { text }+, + (text|anyElement)*) + | (attribute * { text }*, + (text?, anyElement+, (text|anyElement)*)) + } + +extensionElement = + simpleExtensionElement | structuredExtensionElement + +undefinedAttribute = + attribute * - (xml:base | xml:lang | local:*) { text } + +undefinedContent = (text|anyForeignElement)* + +anyElement = + element * { + (attribute * { text } + | text + | anyElement)* + } + +anyForeignElement = + element * - atom:* { + (attribute * { text } + | text + | anyElement)* + } + +anyXHTML = element xhtml:* { + (attribute * { text } + | text + | anyXHTML)* +} + +xhtmlDiv = element xhtml:div { + (attribute * { text } + | text + | anyXHTML)* +}