Validate.java

package jasper.component;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.jsontypedef.jtd.JacksonAdapter;
import com.jsontypedef.jtd.MaxDepthExceededException;
import com.jsontypedef.jtd.Schema;
import com.jsontypedef.jtd.Validator;
import io.micrometer.core.annotation.Timed;
import jasper.config.Config.SecurityConfig;
import jasper.config.Config.ServerConfig;
import jasper.domain.Ext;
import jasper.domain.Plugin;
import jasper.domain.Ref;
import jasper.domain.Template;
import jasper.errors.DuplicateTagException;
import jasper.errors.InvalidPluginException;
import jasper.errors.InvalidPluginUserUrlException;
import jasper.errors.InvalidTemplateException;
import jasper.errors.PublishDateException;
import jasper.repository.RefRepository;
import jasper.security.Auth;
import jasper.service.dto.TemplateDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.support.ScopeNotActiveException;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.Objects;

import static jasper.component.Meta.expandTags;
import static jasper.domain.proj.Tag.matchesTemplate;
import static jasper.domain.proj.Tag.urlForTag;
import static jasper.repository.spec.QualifiedTag.qt;
import static jasper.security.AuthoritiesConstants.EDITOR;
import static jasper.security.AuthoritiesConstants.MOD;

@Component
public class Validate {
	private static final Logger logger = LoggerFactory.getLogger(Validate.class);

	@Autowired
	Auth auth;

	@Autowired
	RefRepository refRepository;

	@Autowired
	Validator validator;

	@Autowired
	ObjectMapper objectMapper;

	@Autowired
	ConfigCache configs;

	@Timed("jasper.validate")
	public void ref(String rootOrigin, Ref ref) {
		ref(rootOrigin, ref, false);
	}

	@Timed("jasper.validate")
	public void ref(String rootOrigin, Ref ref, boolean stripOnError) {
		var root = configs.root();
		try {
			if (!auth.hasRole(MOD)) ref.removeTags(root.getModSeals());
			if (!auth.hasRole(EDITOR)) ref.removeTags(root.getEditorSeals());
		} catch (ScopeNotActiveException e) {
			ref.removeTags(root.getModSeals());
			ref.removeTags(root.getEditorSeals());
		}
		tags(rootOrigin, ref);
		plugins(rootOrigin, ref, stripOnError);
		responses(rootOrigin, ref, true);
		sources(rootOrigin, ref, true);
		responses(rootOrigin, ref, false);
		sources(rootOrigin, ref, false);
	}

	@Timed("jasper.validate")
	public void response(String rootOrigin, Ref ref) {
		var root = configs.root();
		try {
			if (!auth.hasRole(MOD)) ref.removeTags(root.getModSeals());
			if (!auth.hasRole(EDITOR)) ref.removeTags(root.getEditorSeals());
		} catch (ScopeNotActiveException e) {
			ref.removeTags(root.getModSeals());
			ref.removeTags(root.getEditorSeals());
		}
		tags(rootOrigin, ref);
		plugins(rootOrigin, ref, false);
	}

	@Timed("jasper.validate")
	public void ext(String rootOrigin, Ext ext) {
		ext(rootOrigin, ext,false);
	}

	@Timed("jasper.validate")
	public void ext(String rootOrigin, Ext ext, boolean stripOnError) {
		var templates = configs.getSchemas(ext.getTag(), rootOrigin);
		if (templates.isEmpty()) {
			// If an ext has no template, or the template is schemaless, no config is allowed
			if (ext.getConfig() != null && !ext.getConfig().isEmpty()) throw new InvalidTemplateException(ext.getTag());
			return;
		}
		var defaults = configs.getDefaults(ext.getTag(), rootOrigin);
		var mergedDefaults = defaults
			.stream()
			.map(TemplateDto::getDefaults)
			.filter(Objects::nonNull)
			.reduce(null, this::merge);
		if (ext.getConfig() == null) {
			ext.setConfig(mergedDefaults);
			stripOnError = true;
		}
		var mergedSchemas = templates
			.stream()
			.map(TemplateDto::getSchema)
			.filter(Objects::nonNull)
			.reduce(null, this::merge);
		var schema = objectMapper.convertValue(mergedSchemas, Schema.class);
		if (stripOnError) {
			try {
				template(rootOrigin, schema, ext.getTag(), mergedDefaults);
			} catch (Exception e) {
				logger.error("{} Defaults for {} Template do not pass validation", rootOrigin, ext.getTag());
				// Defaults don't validate anyway,
				// so cancel stripping plugins to pass validation
				stripOnError = false;
			}
		}
		try {
			template(rootOrigin, schema, ext.getTag(), ext.getConfig());
		} catch (Exception e) {
			if (!stripOnError) throw e;
			template(rootOrigin, schema, ext.getTag(), mergedDefaults);
			ext.setConfig(mergedDefaults);
		}
	}

	@Timed("jasper.validate")
	public void plugin(String rootOrigin, Plugin plugin) {

	}

	@Timed("jasper.validate")
	public void template(String rootOrigin, Template template) {
		try {
			switch (template.getTag()) {
				case "_config/server":
					objectMapper.convertValue(template.getConfig(), ServerConfig.class);
					break;
				case "_config/security":
					objectMapper.convertValue(template.getConfig(), SecurityConfig.class);
					break;
			}
		} catch (Exception e) {
			throw new InvalidTemplateException(template.getTag());
		}
	}

	public ObjectNode templateDefaults(String qualifiedTag) {
		var qt = qt(qualifiedTag);
		var templates = configs.getSchemas(qt.tag, qt.origin);
		return templates
			.stream()
			.map(TemplateDto::getDefaults)
			.reduce(null, this::merge);
	}

	private void template(String rootOrigin, Schema schema, String tag, JsonNode template) {
		if (template == null || template.isNull()) {
			// Allow null to stand in for empty config
			if (schema.getOptionalProperties() != null) {
				template = objectMapper.createObjectNode();
			} else if (template == null) {
				template = NullNode.getInstance();
			}
		}
		try {
			var errors = validator.validate(schema, new JacksonAdapter(template));
			for (var error : errors) {
				logger.debug("{} Error validating template {}: {}", rootOrigin, tag, error);
			}
			if (!errors.isEmpty()) {
				throw new InvalidTemplateException(tag + ": " + errors);
			}
		} catch (MaxDepthExceededException e) {
			throw new InvalidTemplateException(tag, e);
		}
	}

	private void tags(String rootOrigin, Ref ref) {
		if (ref.getTags() == null) return;
		if (!ref.getTags().stream().allMatch(new HashSet<>()::add)) {
			throw new DuplicateTagException();
		}
	}

	private void plugins(String rootOrigin, Ref ref, boolean stripOnError) {
		if (ref.getPlugins() != null) {
			// Plugin fields must be tagged
			var strip = new ArrayList<String>();
			ref.getPlugins().fieldNames().forEachRemaining(field -> {
				if (!ref.hasTag(field)) {
					logger.debug("{} Plugin missing tag: {}", rootOrigin, field);
					if (!stripOnError) throw new InvalidPluginException(field);
					strip.add(field);
				}
			});
			strip.forEach(field -> ref.getPlugins().remove(field));
		}
		for (var tag : expandTags(ref.getTags())) {
			plugin(rootOrigin, ref, tag, stripOnError);
		}
	}

	ObjectNode merge(ObjectNode a, ObjectNode b) {
		if (a == null && b == null) return objectMapper.createObjectNode();
		if (a == null) return b.deepCopy();
		if (b == null) return a.deepCopy();
		if (!a.isObject() || !b.isObject()) return b.deepCopy();
		b.fieldNames().forEachRemaining(field -> {
			var aNode = a.get(field);
			var bNode = b.get(field);
			if (aNode != null && aNode.isObject() && bNode.isObject()) {
				merge((ObjectNode) aNode, (ObjectNode) bNode);
			} else {
				a.set(field, bNode.deepCopy());
			}
		});
		return a;
	}

	private void plugin(String rootOrigin, Ref ref, String tag, boolean stripOnError) {
		userUrl(ref, tag);
		var plugin = configs.getPlugin(tag, rootOrigin);
		if (plugin.isEmpty() || plugin.get().getSchema() == null) {
			// If a tag has no plugin, or the plugin is schemaless, plugin data is not allowed
			if (ref.hasPlugin(tag)) {
				logger.debug("{} Plugin data not allowed: {}", rootOrigin, tag);
				if (!stripOnError) throw new InvalidPluginException(tag);
				ref.getPlugins().remove(tag);
			}
			return;
		}
		var defaults = plugin.map(Plugin::getDefaults).orElse(null);
		if (!ref.hasPlugin(tag)) {
			ref.setPlugin(tag, defaults);
			stripOnError = true;
		}
		var schema = objectMapper.convertValue(plugin.get().getSchema(), Schema.class);
		if (stripOnError) {
			try {
				plugin(rootOrigin, schema, tag, defaults);
			} catch (Exception e) {
				logger.error("{} Defaults for {} Plugin do not pass validation", rootOrigin, tag);
				// Defaults don't validate anyway,
				// so cancel stripping plugins to pass validation
				stripOnError = false;
			}
		}
		try {
			plugin(rootOrigin, schema, tag, ref.getPlugin(tag));
		} catch (Exception e) {
			if (!stripOnError) throw e;
			ref.setPlugin(tag, defaults);
		}
	}

	private void userUrl(Ref ref, String plugin) {
		if (!matchesTemplate("plugin/user", plugin)) return;
		if (ref.getSources() == null || ref.getSources().size() != 1) {
			throw new InvalidPluginUserUrlException(plugin);
		}
		var userTag = ref.getTags().stream().filter(t -> t.startsWith("+user") || t.startsWith("_user")).findFirst();
		if (userTag.isEmpty()) {
			throw new InvalidPluginUserUrlException(plugin);
		}
		var target = ref.getSources().getFirst();
		if (!ref.getUrl().startsWith(urlForTag(target, userTag.get()))) {
			throw new InvalidPluginUserUrlException(plugin);
		}
	}

	public ObjectNode pluginDefaults(String rootOrigin, Ref ref) {
		var result = objectMapper.getNodeFactory().objectNode();
		for (var tag : expandTags(ref.getTags())) {
			var plugin = configs.getPlugin(tag, rootOrigin);
			plugin.ifPresent(p -> {
				if (p.getDefaults() != null && (p.getDefaults().isValueNode() || !p.getDefaults().isEmpty())) result.set(tag, p.getDefaults());
			});
		}
		if (ref.getPlugins() != null) return merge(result, ref.getPlugins());
		return result;
	}

	private void plugin(String rootOrigin, Schema schema, String tag, JsonNode plugin) {
		if (plugin == null || plugin.isNull()) {
			// Allow null to stand in for empty objects or arrays
			if (schema.getOptionalProperties() != null) {
				plugin = objectMapper.createObjectNode();
			} else if (schema.getElements() != null) {
				plugin = objectMapper.createArrayNode();
			} else if (plugin == null) {
				plugin = NullNode.getInstance();
			}
		}
		try {
			var errors = validator.validate(schema, new JacksonAdapter(plugin));
			for (var error : errors) {
				logger.debug("{} Error validating plugin {}: {}", rootOrigin, tag, error);
			}
			if (!errors.isEmpty()) {
				throw new InvalidPluginException(tag + ": " + errors);
			}
		} catch (MaxDepthExceededException e) {
			throw new InvalidPluginException(tag, e);
		}
	}

	private void sources(String rootOrigin, Ref ref, boolean fix) {
		if (ref.getSources() == null) return;
		for (var sourceUrl : ref.getSources()) {
			if (sourceUrl.equals(ref.getUrl())) continue;
			var sources = refRepository.findAllPublishedByUrlAndPublishedGreaterThanEqual(sourceUrl, rootOrigin, ref.getPublished());
			for (var source : sources) {
				if (source.getPublished().isAfter(ref.getPublished())) {
					if (!fix) throw new PublishDateException(source.getUrl(), ref.getUrl());
					ref.setPublished(source.getPublished().plusMillis(1));
				}
			}
		}
	}

	private void responses(String rootOrigin, Ref ref, boolean fix) {
		var responses = refRepository.findAllResponsesPublishedBeforeThanEqual(ref.getUrl(), rootOrigin, ref.getPublished());
		for (var response : responses) {
			if (response.getPublished().isBefore(ref.getPublished())) {
				if (response.hasTag("plugin/user")) {
					response.setPublished(ref.getPublished());
					continue;
				}
				if (!fix) throw new PublishDateException(response.getUrl(), ref.getUrl());
				ref.setPublished(response.getPublished().minusMillis(1));
			}
		}
	}
}