RefService.java

package jasper.service;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.fge.jsonpatch.JsonPatchException;
import com.github.fge.jsonpatch.Patch;
import io.micrometer.core.annotation.Timed;
import jasper.component.ConfigCache;
import jasper.component.Ingest;
import jasper.component.Validate;
import jasper.domain.Ref;
import jasper.errors.InvalidPatchException;
import jasper.errors.MaxSourcesException;
import jasper.errors.NotFoundException;
import jasper.repository.RefRepository;
import jasper.repository.filter.RefFilter;
import jasper.security.Auth;
import jasper.service.dto.DtoMapper;
import jasper.service.dto.RefDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashSet;

import static jasper.component.Meta.expandTags;
import static jasper.repository.spec.OriginSpec.isOrigin;
import static jasper.repository.spec.RefSpec.isNotObsolete;
import static jasper.repository.spec.RefSpec.isUrl;
import static jasper.repository.spec.RefSpec.sort;
import static org.springframework.data.domain.PageRequest.of;
import static org.springframework.data.domain.PageRequest.ofSize;

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

	@Autowired
	RefRepository refRepository;

	@Autowired
	Ingest ingest;

	@Autowired
	Auth auth;

	@Autowired
	Validate validate;

	@Autowired
	DtoMapper mapper;

	@Autowired
	ObjectMapper objectMapper;

	@Autowired
	ConfigCache configs;

	@PreAuthorize("@auth.canWriteRef(#ref)")
	@Timed(value = "jasper.service", extraTags = {"service", "ref"}, histogram = true)
	public Instant create(Ref ref) {
		var root = configs.root();
		if (ref.getSources() != null && ref.getSources().size() > root.getMaxSources()) {
			throw new MaxSourcesException(root.getMaxSources(), ref.getSources().size());
		}
		ingest.create(auth.getOrigin(), ref);
		return ref.getModified();
	}

	@PreAuthorize("@auth.canWriteRef(#ref)")
	@Timed(value = "jasper.service", extraTags = {"service", "ref"}, histogram = true)
	public void push(Ref ref) {
		var root = configs.root();
		if (ref.getSources() != null && ref.getSources().size() > root.getMaxSources()) {
			logger.warn("Ignoring max count for push. Max count is set to {}. Ref contains {} sources.", root.getMaxSources(), ref.getSources().size());
		}
		ingest.push(auth.getOrigin(), ref, true, false);
	}

	@Transactional(readOnly = true)
	@PostAuthorize("@auth.canReadRef(returnObject)")
	@Timed(value = "jasper.service", extraTags = {"service", "ref"}, histogram = true)
	public RefDto get(String url, String origin) {
		return refRepository.findOneByUrlAndOrigin(url, origin)
			.or(() -> refRepository.findOne(isUrl(url).and(isOrigin(origin))))
			.map(mapper::domainToDto)
			.orElseThrow(() -> new NotFoundException("Ref " + origin + " " + url));
	}

	@Transactional(readOnly = true)
	@PreAuthorize("@auth.canReadOrigin(#origin)")
	@Timed(value = "jasper.service", extraTags = {"service", "ref"}, histogram = true)
	public Instant cursor(String origin) {
		return refRepository.getCursor(origin);
	}

	@Transactional(readOnly = true)
	@PreAuthorize("@auth.canReadQuery(#filter)")
	@Timed(value = "jasper.service", extraTags = {"service", "ref"}, histogram = true)
	public Page<RefDto> page(RefFilter filter, Pageable pageable) {
		return refRepository
			.findAll(
				sort(
					auth.refReadSpec()
						.and(filter.spec(auth.getUserTag())),
					auth.pageable(pageable)),
				of(pageable.getPageNumber(), pageable.getPageSize()))
			.map(mapper::domainToDto);
	}

	@Transactional(readOnly = true)
	@PreAuthorize("@auth.canReadQuery(#filter)")
	@Timed(value = "jasper.service", extraTags = {"service", "ref"}, histogram = true)
	public long count(RefFilter filter) {
		return refRepository
			.count(
				auth.refReadSpec()
					.and(filter.spec(auth.getUserTag())));
	}

	@PreAuthorize("@auth.canWriteRef(#ref)")
	@Timed(value = "jasper.service", extraTags = {"service", "ref"}, histogram = true)
	public Instant update(Ref ref) {
		var root = configs.root();
		if (ref.getSources() != null && ref.getSources().size() > root.getMaxSources()) {
			throw new MaxSourcesException(root.getMaxSources(), ref.getSources().size());
		}
		var maybeExisting = refRepository.findOneByUrlAndOrigin(ref.getUrl(), ref.getOrigin());
		if (maybeExisting.isEmpty()) throw new NotFoundException("Ref " + ref.getOrigin() + " " + ref.getUrl());
		var existing = maybeExisting.get();
		// Hidden tags cannot be removed
		var hiddenTags = auth.hiddenTags(existing.getExpandedTags());
		ref.addTags(hiddenTags);
		ref.addPlugins(hiddenTags, existing.getPlugins());
		// Unwritable tags may only be removed, plugin data may not be modified
		var unwritableTags = auth.unwritableTags(expandTags(ref.getTags()));
		ref.addPlugins(unwritableTags, existing.getPlugins());
		ingest.update(auth.getOrigin(), ref);
		return ref.getModified();
	}

	@PreAuthorize("@auth.canWriteRef(#url, #origin)")
	@Timed(value = "jasper.service", extraTags = {"service", "ref"}, histogram = true)
	public Instant patch(String url, String origin, Instant cursor, Patch patch) {
		// TODO: disable patching for large refs
		var created = false;
		var ref = refRepository.findOneByUrlAndOrigin(url, origin).orElse(null);
		if (ref == null) {
			created = true;
			var current = refRepository.findAll(isUrl(url).and(isNotObsolete()), ofSize(1));
			ref = current.isEmpty() ? new Ref() : current.getContent().getFirst();
			ref.setUrl(url);
			ref.setOrigin(origin);
		}
		ref.setPlugins(validate.pluginDefaults(auth.getOrigin(), ref));
		try {
			var patched = patch.apply(objectMapper.convertValue(ref, JsonNode.class));
			var updated = objectMapper.treeToValue(patched, Ref.class);
			if (updated.getTags() != null) {
				// Tolerate duplicate tags
				updated.setTags(new ArrayList<>(new LinkedHashSet<>(updated.getTags())));
			}
			// @PreAuthorize annotations are not triggered for calls within the same class
			if (!auth.canWriteRef(updated)) throw new AccessDeniedException("Can't add new tags");
			if (created) {
				return create(updated);
			} else {
				updated.setModified(cursor);
				return update(updated);
			}
		} catch (JsonPatchException | JsonProcessingException e) {
			throw new InvalidPatchException("Ref " + origin + " " + url, e);
		}
	}

	@Transactional
	@PreAuthorize("@auth.canWriteRef(#url, #origin) or @auth.subOrigin(#origin) and @auth.hasRole('MOD')")
	@Timed(value = "jasper.service", extraTags = {"service", "ref"}, histogram = true)
	public void delete(String url, String origin) {
		try {
			ingest.delete(auth.getOrigin(), url, origin);
		} catch (EmptyResultDataAccessException e) {
			// Delete is idempotent
		}
	}
}