package com.psddev.cms.db;
import java.io.IOException;
import java.io.StringWriter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.jsp.JspException;
import javax.servlet.jsp.tagext.BodyContent;
import javax.servlet.jsp.tagext.BodyTagSupport;
import javax.servlet.jsp.tagext.DynamicAttributes;
import javax.servlet.jsp.tagext.Tag;
import javax.servlet.jsp.tagext.TryCatchFinally;
import com.psddev.cms.tool.CmsTool;
import com.psddev.dari.db.Application;
import com.psddev.dari.db.ObjectField;
import com.psddev.dari.db.ObjectType;
import com.psddev.dari.db.Reference;
import com.psddev.dari.db.ReferentialText;
import com.psddev.dari.db.State;
import com.psddev.dari.util.HtmlGrid;
import com.psddev.dari.util.HtmlNode;
import com.psddev.dari.util.HtmlWriter;
import com.psddev.dari.util.LazyWriter;
import com.psddev.dari.util.ObjectUtils;
import com.psddev.dari.util.StringUtils;
/**
* Renders the given {@code value} safely in HTML context.
*
* <p>If the value is blank, the expression inside the tag is evaluated.
* For example, given the following script where <code>${foo}</code> is
* {@code null}:</p>
*
* <blockquote><pre><code data-type="java">{@literal
*<cms:render value="${foo}">
* This is the fallback text.
*</cms:render>
* }</code></pre></blockquote>
*
* <p>The output would be {@code This is the fallback text.}</p>
*
* <p>If the value is an instance of {@link Iterable}, each item in it is
* rendered in order.</p>
*
* <p>If the value is an instance of {@link ReferentialText}, the text is
* written to the output as-is, and the objects in the references are rendered
* according to the rules here.</p>
*
* <p>If the value is an instance of {@link String}, unsafe characters are
* escaped, and the result is written to the output.</p>
*
* <p>Otherwise, the value is rendered using {@link PageFilter#renderObject}.
* </p>
*/
public class RenderTag extends BodyTagSupport implements DynamicAttributes, TryCatchFinally {
private static final long serialVersionUID = 1L;
private static final Pattern EMPTY_PARAGRAPH_PATTERN = Pattern.compile("(?is)\\s*<p[^>]*>\\s* \\s*</p>\\s*");
private static final String FIELD_ACCESS_MARKER_BEGIN = "\ue014\ue027\ue041";
private static final String FIELD_ACCESS_MARKER_END = "\ue068\ue077\ue063";
private static final String REFERENCE_ATTRIBUTE = "reference";
private String area;
private String context;
private Object value;
private String beginMarker;
private int beginOffset;
private String endMarker;
private int endOffset;
private final Map<String, String> attributes = new LinkedHashMap<String, String>();
private transient HtmlWriter pageWriter;
private transient LayoutTag layoutTag;
private transient Map<String, Object> areas;
private transient FieldAccessListener fieldAccessListener;
public void setArea(String area) {
this.area = area;
}
public void setContext(String context) {
this.context = context;
}
public void setValue(Object value) {
this.value = value;
}
public void setBeginMarker(String beginMarker) {
this.beginMarker = beginMarker;
}
public void setBeginOffset(int beginOffset) {
this.beginOffset = beginOffset;
}
public void setEndMarker(String endMarker) {
this.endMarker = endMarker;
}
public void setEndOffset(int endOffset) {
this.endOffset = endOffset;
}
public void setMarker(String marker) {
setBeginMarker(marker);
setEndMarker(marker);
}
public void setOffset(int offset) {
setBeginOffset(offset - 1);
setEndOffset(offset);
}
// --- DynamicAttributes support ---
@Override
public void setDynamicAttribute(String uri, String localName, Object value) {
if (value != null) {
attributes.put(localName, value.toString());
}
}
// --- TagSupport support ---
@Override
@SuppressWarnings("deprecation")
public int doStartTag() throws JspException {
HttpServletRequest request = (HttpServletRequest) pageContext.getRequest();
if (!ObjectUtils.isBlank(context)) {
ContextTag.Static.pushContext(request, context);
}
try {
pageWriter = new HtmlWriter(pageContext.getOut());
layoutTag = null;
areas = null;
for (Tag parent = getParent(); parent != null; parent = parent.getParent()) {
if (parent instanceof RenderTag) {
break;
} else if (parent instanceof LayoutTag) {
layoutTag = ((LayoutTag) parent);
areas = layoutTag.getAreas();
break;
}
}
if (ObjectUtils.isBlank(value)) {
if (areas != null) {
return EVAL_BODY_BUFFERED;
} else {
if (!attributes.isEmpty()) {
pageWriter.writeStart("div", attributes);
}
setBodyContent(null);
return EVAL_BODY_INCLUDE;
}
} else {
if (value instanceof Map) {
for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
writeArea(request, entry.getKey(), entry.getValue());
}
} else if (value instanceof Iterable &&
!(value instanceof ReferentialText)) {
int index = 0;
for (Object item : (Iterable<?>) value) {
writeArea(request, index, item);
++ index;
}
} else if (value instanceof Page.Area) {
Page.Area pageArea = (Page.Area) value;
writeArea(request, pageArea.getInternalName(), pageArea.getContents());
} else if (value instanceof Section) {
Section section = (Section) value;
writeArea(request, section.getInternalName(), section);
} else {
writeArea(request, area, value);
}
setBodyContent(null);
return SKIP_BODY;
}
} catch (IOException error) {
throw new JspException(error);
} catch (ServletException error) {
throw new JspException(error);
}
}
@Override
public void doInitBody() {
if (ObjectUtils.to(boolean.class, pageContext.getRequest().getParameter("_fields"))) {
fieldAccessListener = new FieldAccessListener();
State.Static.addListener(fieldAccessListener);
}
}
@Override
public int doAfterBody() {
if (fieldAccessListener != null) {
State.Static.removeListener(fieldAccessListener);
fieldAccessListener = null;
BodyContent bodyContent = getBodyContent();
String oldBody = bodyContent.getString();
StringWriter newBody = new StringWriter();
LazyWriter newBodyLazy = new LazyWriter((HttpServletRequest) pageContext.getRequest(), newBody);
int beginAt;
int endAt = 0;
try {
while ((beginAt = oldBody.indexOf(FIELD_ACCESS_MARKER_BEGIN, endAt)) > -1) {
newBodyLazy.write(oldBody.substring(endAt, beginAt));
endAt = oldBody.indexOf(FIELD_ACCESS_MARKER_END, beginAt);
if (endAt > -1) {
newBodyLazy.writeLazily(oldBody.substring(beginAt + FIELD_ACCESS_MARKER_BEGIN.length(), endAt));
endAt += FIELD_ACCESS_MARKER_END.length();
} else {
newBodyLazy.write(oldBody.substring(beginAt, beginAt + FIELD_ACCESS_MARKER_BEGIN.length()));
endAt = beginAt + FIELD_ACCESS_MARKER_BEGIN.length();
}
}
newBodyLazy.write(oldBody.substring(endAt));
newBodyLazy.writePending();
bodyContent.clearBody();
bodyContent.write(newBody.toString());
} catch (IOException error) {
// Should never happen when writing to StringWriter.
}
}
return SKIP_BODY;
}
private void writeArea(HttpServletRequest request, Object area, Object value) throws IOException, ServletException {
if (layoutTag != null && areas != null) {
if (!ObjectUtils.isBlank(area)) {
HtmlGrid oldGrid = (HtmlGrid) request.getAttribute("grid");
Object oldGridArea = request.getAttribute("gridArea");
StringWriter body = new StringWriter();
boolean contextSet = false;
try {
HtmlGrid grid = layoutTag.getGrid(request, area);
Object gridArea = layoutTag.getAreaName(request, area);
if (grid != null) {
String context = grid.getContexts().get(String.valueOf(gridArea));
if (!ObjectUtils.isBlank(context)) {
contextSet = true;
ContextTag.Static.pushContext(request, context);
}
}
request.setAttribute("grid", grid);
request.setAttribute("gridArea", gridArea);
writeValueWithAttributes(new HtmlWriter(body), value);
areas.put(area.toString(), body.toString());
} finally {
if (contextSet) {
ContextTag.Static.popContext(request);
}
request.setAttribute("grid", oldGrid);
request.setAttribute("gridArea", oldGridArea);
}
}
} else {
writeValueWithAttributes(pageWriter, value);
}
}
private void writeValueWithAttributes(HtmlWriter writer, Object value) throws IOException, ServletException {
if (attributes.isEmpty()) {
writeValue(writer, value);
} else {
writer.writeStart("div", attributes);
writeValue(writer, value);
writer.writeEnd();
}
}
@SuppressWarnings("deprecation")
private void writeValue(HtmlWriter writer, Object value) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) pageContext.getRequest();
HttpServletResponse response = (HttpServletResponse) pageContext.getResponse();
if (value instanceof ReferentialText) {
List<Object> items = ((ReferentialText) value).toPublishables(new RichTextCleaner());
// Slice items based on markers.
if (!(items.isEmpty() || (ObjectUtils.isBlank(beginMarker) && ObjectUtils.isBlank(endMarker)))) {
int beginIndex = 0;
int endIndex = items.size();
if (!ObjectUtils.isBlank(beginMarker)) {
beginIndex = findMarker(items, beginMarker, beginOffset);
}
if (!ObjectUtils.isBlank(endMarker)) {
endIndex = findMarker(items, endMarker, endOffset);
}
if (beginIndex >= 0 && endIndex >= 0) {
if (beginIndex >= endIndex) {
items = items.subList(endIndex, beginIndex);
} else {
items = items.subList(beginIndex, endIndex);
}
} else if (beginIndex < 0 && endIndex >= 0) {
items = items.subList(0, endIndex);
} else if (endIndex < 0 && beginIndex >= 0) {
items = items.subList(beginIndex, items.size());
} else if (beginOffset >= 0 || endOffset != 0) {
items.clear();
}
}
Application.Static.getInstance(CmsTool.class).writeCss(request, writer);
for (Object item : items) {
if (item instanceof String) {
writer.write(EMPTY_PARAGRAPH_PATTERN.matcher((String) item).replaceAll(""));
} else if (item instanceof Reference) {
Object oldReferenceAttribute = null;
Map<String, Object> oldAttributes = new LinkedHashMap<String, Object>();
try {
Reference itemReference = (Reference) item;
Object object = itemReference.getObject();
if (object != null && !(object instanceof ReferentialTextMarker)) {
oldReferenceAttribute = request.getAttribute(REFERENCE_ATTRIBUTE);
request.setAttribute(REFERENCE_ATTRIBUTE, itemReference);
// For backward compatibility, ensure these field values are set directly as request attributes
for (ObjectField field : ObjectType.getInstance(RichTextReference.class).getFields()) {
String fieldName = field.getInternalName();
String fieldNamePc = StringUtils.toCamelCase(fieldName);
oldAttributes.put(fieldName, request.getAttribute(fieldName));
oldAttributes.put(fieldNamePc, request.getAttribute(fieldNamePc));
request.setAttribute(fieldName, itemReference.getState().get(fieldName));
request.setAttribute(fieldNamePc, itemReference.getState().get(fieldName));
}
PageFilter.renderObject(request, response, writer, object);
}
} finally {
request.setAttribute(REFERENCE_ATTRIBUTE, oldReferenceAttribute);
for (Map.Entry<String, Object> entry : oldAttributes.entrySet()) {
request.setAttribute(entry.getKey(), entry.getValue());
}
}
}
}
} else if (value instanceof Map) {
for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) {
writeValue(writer, entry.getValue());
}
} else if (value instanceof Iterable) {
for (Object item : (Iterable<?>) value) {
writeValue(writer, item);
}
} else if (value instanceof Page.Area) {
writeValue(writer, ((Page.Area) value).getContents());
} else if (value instanceof String) {
writer.html(value);
} else if (value instanceof HtmlNode) {
((HtmlNode) value).writeHtml(writer);
} else {
PageFilter.renderObject(request, response, writer, value);
}
}
private int findMarker(List<Object> items, String internalName, int offset) {
int itemIndex = 0;
int markerIndex = 0;
for (Object item : items) {
if (item instanceof Reference) {
Object referenced = ((Reference) item).getObject();
if (referenced instanceof ReferentialTextMarker &&
internalName.equals(((ReferentialTextMarker) referenced).getInternalName())) {
if (offset == markerIndex) {
return itemIndex;
} else {
++ markerIndex;
}
}
}
++ itemIndex;
}
return -1;
}
@Override
public int doEndTag() throws JspException {
HttpServletRequest request = (HttpServletRequest) pageContext.getRequest();
if (!ObjectUtils.isBlank(context)) {
ContextTag.Static.popContext(request);
}
try {
if (ObjectUtils.isBlank(value)) {
if (bodyContent != null) {
String body = bodyContent.getString();
if (body != null) {
if (areas != null) {
if (!ObjectUtils.isBlank(area)) {
if (!attributes.isEmpty()) {
StringWriter stringWriter = new StringWriter();
@SuppressWarnings("resource")
HtmlWriter htmlWriter = new HtmlWriter(stringWriter);
htmlWriter.writeStart("div", attributes);
htmlWriter.write(body);
htmlWriter.writeEnd();
body = stringWriter.toString();
}
areas.put(area, body);
}
} else {
pageWriter.write(body);
if (!attributes.isEmpty()) {
pageWriter.writeEnd();
}
}
}
}
}
return EVAL_PAGE;
} catch (IOException error) {
throw new JspException(error);
}
}
// --- TryCatchFinally support ---
@Override
public void doCatch(Throwable error) throws Throwable {
throw error;
}
@Override
public void doFinally() {
doAfterBody();
setArea(null);
setContext(null);
setValue(null);
setBeginMarker(null);
setBeginOffset(0);
setEndMarker(null);
setEndOffset(0);
attributes.clear();
pageWriter = null;
layoutTag = null;
areas = null;
fieldAccessListener = null;
}
private class FieldAccessListener extends State.Listener {
@Override
public void beforeFieldGet(State state, String name) {
if (!FieldAccessFilter.Static.getDisplayIds((HttpServletRequest) pageContext.getRequest()).contains(state.getId())) {
return;
}
BodyContent bodyContent = getBodyContent();
try {
bodyContent.write(FIELD_ACCESS_MARKER_BEGIN);
bodyContent.write(FieldAccessFilter.createMarkerHtml(state, name));
bodyContent.write(FIELD_ACCESS_MARKER_END);
} catch (IOException error) {
// Should never happen when writing to BodyContent.
}
}
}
}