plugins;
+ private boolean obsolete;
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/dto/RefUpdateDto.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/dto/RefUpdateDto.java
new file mode 100644
index 000000000..0e0cfb16c
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/dto/RefUpdateDto.java
@@ -0,0 +1,37 @@
+package jasper.component.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jasper.domain.proj.HasTags;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+import java.time.Instant;
+import java.util.List;
+
+import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY;
+
+/**
+ * DTO for sending a Ref update to a subscribed client.
+ *
+ * We can't verify what tags the user has access to, so all private tags
+ * metadata, and plugins are dropped.
+ */
+@Getter
+@Setter
+@JsonInclude(NON_EMPTY)
+public class RefUpdateDto implements HasTags, Serializable {
+ private String url;
+ private String origin;
+ private String title;
+ private String comment;
+ private List tags;
+ private List sources;
+ private List alternateUrls;
+ private ObjectNode plugins;
+ private MetadataUpdateDto metadata;
+ private Instant published;
+ private Instant created;
+ private Instant modified;
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/dto/ScimPatchOp.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/dto/ScimPatchOp.java
new file mode 100644
index 000000000..707d3af44
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/dto/ScimPatchOp.java
@@ -0,0 +1,34 @@
+package jasper.component.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.List;
+
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ScimPatchOp {
+ @Builder.Default
+ private List schemas = List.of("urn:ietf:params:scim:api:messages:2.0:PatchOp");
+ @JsonProperty("Operations")
+ private List operations;
+
+ @Getter
+ @Setter
+ @Builder
+ @NoArgsConstructor
+ @AllArgsConstructor
+ public static class Operation {
+ private String op;
+ private String path;
+ private Object value;
+ }
+
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/dto/ScimUserResource.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/dto/ScimUserResource.java
new file mode 100644
index 000000000..2225649db
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/dto/ScimUserResource.java
@@ -0,0 +1,28 @@
+package jasper.component.dto;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.unboundid.scim2.common.types.Email;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.util.List;
+
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ScimUserResource {
+ @Builder.Default
+ private List schemas = List.of("urn:ietf:params:scim:schemas:core:2.0:User");
+ private String id;
+ @Builder.Default
+ private boolean active = true;
+ private String userName;
+ private String password;
+ private ObjectNode customClaims;
+ private List emails;
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/DeltaCache.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/DeltaCache.java
new file mode 100644
index 000000000..eb1058b4c
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/DeltaCache.java
@@ -0,0 +1,36 @@
+package jasper.component.script;
+
+import jasper.component.FileCache;
+import jasper.component.Tagger;
+import jasper.domain.Ref;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+import static jasper.util.Logging.getMessage;
+
+@Profile("proxy & file-cache")
+@Component
+public class DeltaCache {
+ private static final Logger logger = LoggerFactory.getLogger(DeltaCache.class);
+
+ @Autowired
+ FileCache fileCache;
+
+ @Autowired
+ Tagger tagger;
+
+ public void runScript(Ref ref) {
+ logger.info("{} Caching {}", ref.getOrigin(), ref.getUrl());
+ tagger.tag(ref.getUrl(), ref.getOrigin(), "-_plugin/delta/cache", "_plugin/cache");
+ try {
+ fileCache.refresh(ref.getUrl(), ref.getOrigin());
+ } catch (Exception e) {
+ tagger.attachError(ref.getOrigin(),
+ tagger.plugin(ref.getUrl(), ref.getOrigin(), "_plugin/cache", null, "-_plugin/delta/cache"),
+ "Error Fetching for _plugin/delta/cache", getMessage(e));
+ }
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/DeltaScrape.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/DeltaScrape.java
new file mode 100644
index 000000000..1edb46df0
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/DeltaScrape.java
@@ -0,0 +1,71 @@
+package jasper.component.script;
+
+import jakarta.persistence.EntityManager;
+import jasper.component.Ingest;
+import jasper.component.Scraper;
+import jasper.component.Tagger;
+import jasper.domain.Ref;
+import jasper.errors.ModifiedException;
+import jasper.errors.NotFoundException;
+import jasper.repository.RefRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+import static jasper.domain.proj.HasOrigin.origin;
+import static jasper.domain.proj.Tag.matchesTag;
+import static jasper.util.Logging.getMessage;
+
+@Profile("proxy")
+@Component
+public class DeltaScrape {
+ private static final Logger logger = LoggerFactory.getLogger(DeltaScrape.class);
+
+ @Autowired
+ Scraper scraper;
+
+ @Autowired
+ Ingest ingest;
+
+ @Autowired
+ Tagger tagger;
+
+ @Autowired
+ RefRepository refRepository;
+
+ @Autowired
+ EntityManager em;
+
+ public void runScript(Ref ref) {
+ try {
+ logger.info("{} Scraping {}", ref.getOrigin(), ref.getUrl());
+ var tags = ref.getExpandedTags();
+ tagger.tag(ref.getUrl(), ref.getOrigin(), "-_plugin/delta/scrape");
+ var web = scraper.web(ref.getUrl(), ref.getOrigin());
+ // Fetch Ref again in case scrape modified it
+ ref = fetch(ref.getUrl(), ref.getOrigin());
+ em.detach(ref);
+ var scrapeAll = tags.stream().anyMatch(t -> matchesTag("_plugin/delta/scrape/ref", t));
+ if (scrapeAll || tags.stream().anyMatch(t -> matchesTag("_plugin/delta/scrape/title", t))) ref.setTitle(web.getTitle());
+ if (scrapeAll || tags.stream().anyMatch(t -> matchesTag("_plugin/delta/scrape/comment", t))) ref.setComment(web.getComment());
+ if (scrapeAll || tags.stream().anyMatch(t -> matchesTag("_plugin/delta/scrape/sources", t))) ref.setSources(web.getSources());
+ if (scrapeAll || tags.stream().anyMatch(t -> matchesTag("_plugin/delta/scrape/alts", t))) ref.setAlternateUrls(web.getAlternateUrls());
+ if (scrapeAll || tags.stream().anyMatch(t -> matchesTag("_plugin/delta/scrape/plugins", t))) ref.setPlugins(web.getPlugins());
+ if (scrapeAll || tags.stream().anyMatch(t -> matchesTag("_plugin/delta/scrape/tags", t))) ref.setTags(web.getTags());
+ if (scrapeAll || tags.stream().anyMatch(t -> matchesTag("_plugin/delta/scrape/published", t))) ref.setPublished(web.getPublished());
+ ref.removeTag("_plugin/delta/scrape");
+ ingest.update(ref.getOrigin(), ref);
+ } catch (ModifiedException ignored) {
+ } catch (Exception e) {
+ logger.warn("{} Unexpected error scraping Ref {}", ref.getOrigin(), ref.getUrl());
+ tagger.attachError(ref.getUrl(), ref.getOrigin(), "Error Fetching for _plugin/delta/scrape", getMessage(e));
+ }
+ }
+
+ private Ref fetch(String url, String origin) {
+ return refRepository.findOneByUrlAndOrigin(url, origin(origin))
+ .orElseThrow(() -> new NotFoundException("Async"));
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/RssParser.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/RssParser.java
new file mode 100644
index 000000000..62a62dade
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/RssParser.java
@@ -0,0 +1,427 @@
+package jasper.component.script;
+
+import com.rometools.modules.itunes.ITunes;
+import com.rometools.modules.mediarss.MediaEntryModuleImpl;
+import com.rometools.modules.mediarss.MediaModule;
+import com.rometools.rome.feed.module.DCModule;
+import com.rometools.rome.feed.synd.SyndContent;
+import com.rometools.rome.feed.synd.SyndEntry;
+import com.rometools.rome.io.FeedException;
+import com.rometools.rome.io.ParsingFeedException;
+import com.rometools.rome.io.SyndFeedInput;
+import com.rometools.rome.io.XmlReader;
+import feign.FeignException;
+import jasper.client.JasperClient;
+import jasper.client.dto.JasperMapper;
+import jasper.component.HttpClientFactory;
+import jasper.component.Ingest;
+import jasper.component.Sanitizer;
+import jasper.component.Tagger;
+import jasper.domain.Ref;
+import jasper.domain.proj.HasTags;
+import jasper.errors.NotFoundException;
+import jasper.plugin.Audio;
+import jasper.plugin.Feed;
+import jasper.plugin.Thumbnail;
+import jasper.plugin.Video;
+import jasper.repository.RefRepository;
+import jasper.security.HostCheck;
+import org.apache.http.HttpHeaders;
+import org.apache.http.client.methods.HttpGet;
+import org.jdom2.input.JDOMParseException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import javax.net.ssl.SSLException;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.net.URL;
+import java.time.Instant;
+import java.time.Year;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static jasper.plugin.Cron.getCron;
+import static jasper.plugin.Feed.getFeed;
+import static jasper.security.Auth.LOCAL_ORIGIN_HEADER;
+import static jasper.security.Auth.USER_ROLE_HEADER;
+import static jasper.security.Auth.USER_TAG_HEADER;
+import static jasper.security.AuthoritiesConstants.USER;
+import static jasper.util.Logging.getMessage;
+import static org.apache.commons.lang3.StringUtils.isBlank;
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+@Component
+public class RssParser {
+ private static final Logger logger = LoggerFactory.getLogger(RssParser.class);
+
+ @Value("http://localhost:${server.port}")
+ String api;
+
+ @Autowired
+ JasperClient jasperClient;
+
+ @Autowired
+ JasperMapper mapper;
+
+ @Autowired
+ HostCheck hostCheck;
+
+ @Autowired
+ Ingest ingest;
+
+ @Autowired
+ Tagger tagger;
+
+ @Autowired
+ Sanitizer sanitizer;
+
+ @Autowired
+ RefRepository refRepository;
+
+ @Autowired
+ HttpClientFactory httpClientFactory;
+
+ public void runScript(Ref ref, String scriptTag) {
+ logger.info("{} Scraping {} feed: {}.", ref.getOrigin(), ref.getTitle(), ref.getUrl());
+ try {
+ scrape(ref, scriptTag);
+ } catch (ParsingFeedException e) {
+ if (e.getLineNumber() == 1 || e.getCause() instanceof JDOMParseException) {
+ // Temporary error page, retry later
+ logger.warn("{} Error parsing feed {}: {}", ref.getOrigin(), ref.getUrl(), getMessage(e));
+ } else {
+ tagger.attachError(ref.getUrl(), ref.getOrigin(), "Error parsing feed", getMessage(e));
+ }
+ } catch (IllegalArgumentException e) {
+ // Temporary error page, retry later
+ logger.warn("{} Error parsing feed {}: {}", ref.getOrigin(), ref.getUrl(), getMessage(e));
+ } catch (FeedException e) {
+ tagger.attachError(ref.getUrl(), ref.getOrigin(), "Error scraping feed", getMessage(e));
+ } catch (SSLException e) {
+ // Temporary error page, retry later
+ tagger.attachLogs(ref.getUrl(), ref.getOrigin(), "Error with feed SSL", getMessage(e));
+ } catch (SocketTimeoutException e) {
+ // Temporary network timeout, retry later
+ tagger.attachLogs(ref.getUrl(), ref.getOrigin(), "Timeout loading feed", getMessage(e));
+ } catch (IOException e) {
+ // Temporary network timeout, retry later
+ tagger.attachLogs(ref.getUrl(), ref.getOrigin(), "Error loading feed", getMessage(e));
+ } catch (Throwable e) {
+ tagger.attachError(ref.getUrl(), ref.getOrigin(), "Unexpected error scraping feed", getMessage(e));
+ }
+ logger.info("{} Finished scraping feed: {}.", ref.getOrigin(), ref.getUrl());
+ }
+
+ private void scrape(Ref feed, String scriptTag) throws IOException, FeedException {
+ var config = getFeed(feed, scriptTag);
+
+ try (var client = httpClientFactory.getClient()) {
+ var request = new HttpGet(feed.getUrl());
+ if (!hostCheck.validHost(request.getURI())) {
+ logger.info("{} Invalid host {}", feed.getOrigin(), request.getURI().getHost());
+ return;
+ }
+
+ if (!config.isDisableEtag() && config.getEtag() != null) {
+ request.setHeader(HttpHeaders.IF_NONE_MATCH, config.getEtag());
+ }
+ Instant lastScrape = null;
+ var cron = getCron(feed);
+ if (cron != null && cron.getInterval() != null) {
+ lastScrape = Instant.now().minus(cron.getInterval());
+ if (lastScrape.isAfter(feed.getModified()) &&
+ lastScrape.isAfter(Instant.now().minus(ManagementFactory.getRuntimeMXBean().getUptime(), ChronoUnit.MILLIS))) {
+ request.setHeader(HttpHeaders.IF_MODIFIED_SINCE, DateTimeFormatter.RFC_1123_DATE_TIME.format(lastScrape.atZone(ZoneId.of("GMT"))));
+ }
+ }
+ request.setHeader(HttpHeaders.USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36");
+ try (var response = client.execute(request)) {
+ if (response.getStatusLine().getStatusCode() == 304) {
+ if (lastScrape == null) {
+ logger.info("{} Feed {} not modified", feed.getOrigin(), feed.getTitle());
+ } else {
+ logger.info("{} Feed {} not modified since {}", feed.getOrigin(), feed.getTitle(), lastScrape);
+ }
+ return;
+ }
+ try (var stream = response.getEntity().getContent()) {
+ if (!config.isDisableEtag()) {
+ var etag = response.getFirstHeader(HttpHeaders.ETAG);
+ if (etag != null && (config.getEtag() == null || !config.getEtag().equals(etag.getValue()))) {
+ config.setEtag(etag.getValue());
+ feed.setPlugin(scriptTag, config);
+ ingest.update(feed.getOrigin(), feed);
+ } else if (etag == null && config.getEtag() != null) {
+ config.setEtag(null);
+ feed.setPlugin(scriptTag, config);
+ ingest.update(feed.getOrigin(), feed);
+ }
+ }
+ var syndFeed = new SyndFeedInput().build(new XmlReader(stream));
+ if (syndFeed.getImage() != null) {
+ var image = syndFeed.getImage().getUrl();
+ cacheLater(image, feed.getOrigin());
+ if (!feed.hasTag("plugin/thumbnail")) {
+ feed.setPlugin("plugin/thumbnail", Thumbnail.builder().url(image).build());
+ ingest.update(feed.getOrigin(), feed);
+ }
+ }
+ for (var entry : syndFeed.getEntries().reversed()) {
+ try {
+ var link = entryLink(feed, config, entry);
+ if (refRepository.existsByUrlAndOrigin(link, feed.getOrigin())) {
+ logger.debug("{} Skipping RSS entry in feed {} which already exists. {} {}",
+ feed.getOrigin(), feed.getTitle(), entry.getTitle(), entry.getLink());
+ continue;
+ }
+ var ref = parseEntry(feed, config, link, entry, config.getDefaultThumbnail());
+ ref.setOrigin(feed.getOrigin());
+ if (ref.getPublished().isBefore(feed.getPublished())) {
+ logger.warn("{} RSS entry in feed {} which was published before feed publish date. {} {}",
+ feed.getOrigin(), feed.getTitle(), ref.getTitle(), ref.getUrl());
+ feed.setPublished(ref.getPublished().minus(1, ChronoUnit.DAYS));
+ ingest.update(feed.getOrigin(), feed);
+ }
+ jasperClient.refPush(URI.create(api), authorHeaders(feed), feed.getOrigin(), List.of(mapper.domainToDto(ref)));
+ } catch (NotFoundException e) {
+ logger.debug("{} Skipping RSS entry in feed {} which failed matching conditions. {} {}",
+ feed.getOrigin(), feed.getTitle(), entry.getTitle(), entry.getLink());
+ } catch (FeignException.Forbidden | FeignException.Unauthorized e) {
+ logger.warn("{} Feed scrape blocked: author not authorized. {}", feed.getOrigin(), feed.getUrl());
+ tagger.attachError(feed.getUrl(), feed.getOrigin(), "Author not authorized to add tags", e.contentUTF8());
+ return; // addTags apply to all entries; if one fails, all will fail
+ } catch (Exception e) {
+ logger.error("{} Error processing entry {}: {}", feed.getOrigin(), feed.getUrl(), entry.getLink());
+ tagger.attachLogs(feed.getOrigin(), feed, "Error processing entry " + entry.getLink(), getMessage(e));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private String entryLink(Ref feed, Feed config, SyndEntry entry) {
+ var link = entry.getLink();
+ if (entry.getUri() != null && entry.getUri().startsWith(link)) {
+ // Atom ID, RSS GUID
+ link = entry.getUri();
+ }
+ if (config.isStripQuery() && link.contains("?")) {
+ if (link.contains("#") && !config.isStripHash()) {
+ link = link.substring(0, link.indexOf("?")) + link.substring(link.indexOf("#"));
+ } else {
+ link = link.substring(0, link.indexOf("?"));
+ }
+ }
+ if (config.isStripHash() && link.contains("#")) {
+ link = link.substring(0, link.indexOf("#"));
+ }
+ try {
+ new URI(link).toURL();
+ } catch (IllegalArgumentException e) {
+ try {
+ link = new URL(new URI(feed.getUrl()).toURL(), link).toExternalForm();
+ } catch (Exception ex) {
+ throw new RuntimeException(ex);
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ return link;
+ }
+
+ private Ref parseEntry(Ref feed, Feed config, String link, SyndEntry entry, Thumbnail defaultThumbnail) {
+ if (config.getMatchText() != null && !config.getMatchText().isEmpty()) {
+ var title = entry.getTitle().toLowerCase();
+ if (config.getMatchText().stream().noneMatch(t -> title.contains(t.toLowerCase()))) {
+ throw new NotFoundException(entry.getTitle());
+ }
+ }
+ var ref = new Ref();
+ if (config.isScrapeWebpage()) {
+ ref.addTag("_plugin/delta/scrape/ref");
+ }
+ ref.setUrl(link);
+ ref.setTitle(entry.getTitle());
+ ref.setSources(List.of(feed.getUrl()));
+ ref.addTags(config.getAddTags());
+ if (entry.getPublishedDate() != null) {
+ ref.setPublished(entry.getPublishedDate().toInstant());
+ } else if (link.contains("arxiv.org")) {
+ var publishDate = link.substring(link.lastIndexOf("/") + 1, link.lastIndexOf("/") + 5);
+ var publishYear = publishDate.substring(0, 2);
+ if (Integer.parseInt("20" + publishYear) > Year.now().getValue() + 10) {
+ publishYear = "19" + publishYear;
+ } else {
+ publishYear = "20" + publishYear;
+ }
+ var publishMonth = publishDate.substring(2);
+ ref.setPublished(Instant.parse(publishYear + "-" + publishMonth + "-01T00:00:00.00Z"));
+ } else if (entry.getUpdatedDate() != null) {
+ ref.setPublished(entry.getUpdatedDate().toInstant());
+ }
+ var comment = isNotBlank(ref.getComment()) ? ref.getComment() : "";
+ if (config.isScrapeDescription() || config.isScrapeContents()) {
+ SyndContent desc = null;
+ if (config.isScrapeContents() && !entry.getContents().isEmpty()) {
+ desc = getPreferredType(entry.getContents());
+ } else if (config.isScrapeDescription() && entry.getDescription() != null) {
+ desc = entry.getDescription();
+ }
+ if (desc != null) {
+ comment = desc.getValue();
+ if (isHtml(desc)) {
+ comment = sanitizer.clean(comment);
+ }
+ }
+ }
+ var dc = (DCModule) entry.getModule(DCModule.URI);
+ if (config.isScrapeAuthors() && !dc.getCreators().isEmpty()) {
+ if (!comment.isBlank()) comment += "\n\n\n\n";
+ comment += String.join(", ", dc.getCreators());
+ }
+ ref.setComment(comment);
+ if (config.isScrapeThumbnail()) {
+ parseThumbnail(entry, ref);
+ if (!ref.hasPlugin("plugin/thumbnail")) {
+ if (defaultThumbnail != null && !defaultThumbnail.isBlank()) {
+ ref.setPlugin("plugin/thumbnail", defaultThumbnail);
+ }
+ }
+ if (ref.hasPlugin("plugin/thumbnail")) {
+ cacheLater(ref.getPlugin("plugin/thumbnail", Thumbnail.class).getUrl(), feed.getOrigin());
+ }
+ }
+ if (config.isScrapeAudio()) {
+ parseAudio(entry, ref);
+ if (ref.hasPlugin("plugin/audio")) {
+ cacheLater(ref.getPlugin("plugin/audio", Audio.class).getUrl(), feed.getOrigin());
+ }
+ }
+ if (config.isScrapeVideo()) {
+ parseVideo(entry, ref);
+ if (ref.hasPlugin("plugin/video")) {
+ cacheLater(ref.getPlugin("plugin/video", Video.class).getUrl(), feed.getOrigin());
+ } else {
+ parseEmbed(entry, ref);
+ }
+ }
+ return ref;
+ }
+
+ private SyndContent getPreferredType(List contents) {
+ for (var c : contents) if (isHtml(c)) return c;
+ for (var c : contents) if (isText(c)) return c;
+ return contents.get(0);
+ }
+
+ private boolean isHtml(SyndContent content) {
+ var t = content.getType();
+ return t != null && (t.equals("text/html") || t.equals("html"));
+ }
+
+ private boolean isText(SyndContent content) {
+ var t = content.getType();
+ // When used for the description of an entry, if null 'text/plain' must be assumed.
+ return t == null || t.equals("text/plain");
+ }
+
+ private void parseThumbnail(SyndEntry entry, Ref ref) {
+ if (entry.getEnclosures() != null) {
+ for (var e : entry.getEnclosures()) {
+ if ("image/jpg".equals(e.getType()) || "image/png".equals(e.getType())) {
+ ref.setPlugin("plugin/thumbnail", Thumbnail.builder().url(e.getUrl()).build());
+ return;
+ }
+ }
+ }
+ var itunes = (ITunes) entry.getModule(ITunes.URI);
+ if (itunes != null &&
+ itunes.getImageUri() != null) {
+ ref.setPlugin("plugin/thumbnail", Map.of("url", itunes.getImageUri()));
+ return;
+ }
+ var media = (MediaEntryModuleImpl) entry.getModule(MediaModule.URI);
+ if (media == null) return;
+ if (media.getMetadata() != null &&
+ media.getMetadata().getThumbnail() != null &&
+ media.getMetadata().getThumbnail().length != 0) {
+ ref.setPlugin("plugin/thumbnail", Map.of("url", media.getMetadata().getThumbnail()[0].getUrl()));
+ return;
+ }
+ if (media.getMetadata() != null &&
+ media.getMediaContents() != null) {
+ for (var c : media.getMediaContents()) {
+ if ("image".equals(c.getMedium()) || "image/jpeg".equals(c.getType()) || "application/octet-stream".equals(c.getType())) {
+ ref.setPlugin("plugin/thumbnail", c.getReference());
+ return;
+ }
+ }
+ }
+ if (media.getMediaGroups().length == 0) return;
+ var group = media.getMediaGroups()[0];
+ if (group.getMetadata().getThumbnail().length == 0) return;
+ ref.setPlugin("plugin/thumbnail", Map.of("url", group.getMetadata().getThumbnail()[0].getUrl()));
+ }
+
+ private void parseAudio(SyndEntry entry, Ref ref) {
+ if (entry.getEnclosures() != null) {
+ for (var e : entry.getEnclosures()) {
+ if (e.getType() == null) continue;
+ if (e.getType().startsWith("audio/")) {
+ ref.setPlugin("plugin/audio", Map.of("url", e.getUrl()));
+ return;
+ }
+ }
+ }
+ }
+
+ private void parseVideo(SyndEntry entry, Ref ref) {
+ if (entry.getEnclosures() != null) {
+ for (var e : entry.getEnclosures()) {
+ if ("video/mp4".equals(e.getType())) {
+ ref.setPlugin("plugin/video", Map.of("url", e.getUrl()));
+ return;
+ }
+ }
+ }
+ }
+
+ private void parseEmbed(SyndEntry entry, Ref ref) {
+ var youtubeEmbed = entry
+ .getForeignMarkup()
+ .stream()
+ .filter(e -> "videoId".equals(e.getName()))
+ .filter(e -> "yt".equals(e.getNamespacePrefix()))
+ .findFirst();
+ if (youtubeEmbed.isPresent()) {
+ ref.setPlugin("plugin/embed", Map.of("url", "https://www.youtube.com/embed/" + youtubeEmbed.get().getValue()));
+ }
+ }
+
+ private Map authorHeaders(Ref feed) {
+ var authors = HasTags.authors(feed);
+ return Map.of(
+ LOCAL_ORIGIN_HEADER, Objects.toString(feed.getOrigin(), ""),
+ USER_TAG_HEADER, authors.isEmpty() ? "" : authors.getFirst(),
+ USER_ROLE_HEADER, USER
+ );
+ }
+
+ private void cacheLater(String url, String origin) {
+ if (isBlank(url)) return;
+ var ref = refRepository.findOneByUrlAndOrigin(url, origin).orElse(null);
+ if (ref != null && (ref.hasTag("_plugin/cache") || ref.hasTag("_plugin/delta/cache"))) return;
+ tagger.internalTag(url, origin, "_plugin/delta/cache");
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/ScriptDefaults.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/ScriptDefaults.java
new file mode 100644
index 000000000..adf3ca5ad
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/script/ScriptDefaults.java
@@ -0,0 +1,34 @@
+package jasper.component.script;
+
+import jasper.domain.Ref;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Optional;
+
+@Component
+public class ScriptDefaults {
+
+ @Autowired
+ RssParser rssParser;
+
+ @Autowired
+ Optional deltaCache;
+
+ @Autowired
+ Optional deltaScrape;
+
+ public void runScript(Ref ref, String scriptTag) {
+ switch (scriptTag) {
+ case "plugin/script/feed":
+ rssParser.runScript(ref, "plugin/script/feed");
+ break;
+ case "_plugin/delta/scrape":
+ if (deltaScrape.isPresent()) deltaScrape.get().runScript(ref);
+ break;
+ case "_plugin/delta/cache":
+ if (deltaCache.isPresent()) deltaCache.get().runScript(ref);
+ break;
+ }
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/JavaScript.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/JavaScript.java
new file mode 100644
index 000000000..47158e752
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/JavaScript.java
@@ -0,0 +1,71 @@
+package jasper.component.vm;
+
+import io.micrometer.core.annotation.Timed;
+import jasper.config.Props;
+import jasper.errors.ScriptException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+
+import static jasper.component.vm.RunProcess.runProcess;
+
+@Component
+public class JavaScript {
+ private static final Logger logger = LoggerFactory.getLogger(JavaScript.class);
+
+ @Autowired
+ Props props;
+
+ @Value("http://localhost:${server.port}")
+ String api;
+
+ // language=JavaScript
+ private final String nodeVmWrapperScript = """
+ const fs = require('fs');
+ const stdin = fs.readFileSync(0, 'utf-8');
+ const timeout = parseInt(process.argv[1], 10) || 30_000;
+ const api = process.argv[2];
+ const [targetScript, inputString] = (i => i < 0 ? [stdin, ''] : [stdin.slice(0, i), stdin.slice(i + 1)])(stdin.indexOf('\\u0000'));
+ const patchedFs = {
+ ...fs,
+ readFileSync: (path, options) => {
+ if (path === 0) return inputString;
+ return fs.readFileSync(path, options);
+ }
+ };
+ const patchedRequire = (mod) => {
+ if (mod === 'fs') return patchedFs;
+ return require(mod);
+ };
+ const scriptProcess = {
+ env: { JASPER_API: api },
+ exit: (code) => process.exit(code),
+ };
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
+ const script = new AsyncFunction('require', 'console', 'setTimeout', 'process', targetScript);
+ script(patchedRequire, console, setTimeout, scriptProcess).catch(err => {
+ console.error(err);
+ process.exit(1);
+ });
+ """;
+
+ @Timed("jasper.vm")
+ public String runJavaScript(String targetScript, String inputString, int timeoutMs) throws ScriptException, IOException {
+ var process = new ProcessBuilder(props.getNode(), "-e", nodeVmWrapperScript, ""+timeoutMs, api).start();
+ try (var writer = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)) {
+ writer.write(targetScript);
+ writer.write("\0"); // null character as delimiter
+ writer.write(inputString);
+ writer.flush();
+ } catch (IOException e) {
+ logger.warn("Script terminated before receiving input.");
+ }
+ return runProcess(process, timeoutMs);
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/Python.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/Python.java
new file mode 100644
index 000000000..12ddadb22
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/Python.java
@@ -0,0 +1,118 @@
+package jasper.component.vm;
+
+import io.micrometer.core.annotation.Timed;
+import jasper.config.Props;
+import jasper.errors.ScriptException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantLock;
+
+import static jasper.component.vm.RunProcess.runProcess;
+import static java.lang.System.getProperty;
+import static java.nio.file.Files.createDirectories;
+import static java.nio.file.Files.exists;
+import static java.nio.file.Files.setAttribute;
+import static java.nio.file.Files.writeString;
+import static java.security.MessageDigest.getInstance;
+import static java.time.Instant.now;
+import static org.apache.commons.codec.binary.Hex.encodeHexString;
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+@Component
+public class Python {
+ private static final Logger logger = LoggerFactory.getLogger(Python.class);
+ private static final Duration UPDATE_COOLDOWN = Duration.of(15, ChronoUnit.MINUTES);
+
+ @Autowired
+ Props props;
+
+ @Value("http://localhost:${server.port}")
+ String api;
+
+ private final Map lastUpdate = new HashMap<>();
+ private final ConcurrentHashMap venvLocks = new ConcurrentHashMap<>();
+
+ // language=Python
+ private final String pythonVmWrapperScript = """
+import sys, os
+import subprocess
+timeout_ms = int(sys.argv[1])
+env = os.environ.copy()
+env['JASPER_API'] = sys.argv[2]
+input_data = sys.stdin.read()
+target_script, input_string = input_data.split('\\0', 1)
+process = subprocess.Popen([sys.executable, '-c', target_script], stdin=subprocess.PIPE, stdout=sys.stdout, stderr=sys.stderr, env=env)
+try:
+ process.communicate(input=input_string.encode(), timeout=timeout_ms / 1000)
+except subprocess.TimeoutExpired:
+ process.kill()
+ sys.exit(1)
+if process.returncode != 0:
+ sys.exit(process.returncode)
+ """;
+
+ @Timed("jasper.vm")
+ public String runPython(String requirements, String targetScript, String inputString, int timeoutMs) throws ScriptException, IOException, NoSuchAlgorithmException {
+ var python = props.getPython();
+ if (isNotBlank(requirements)) {
+ var requirementsHash = encodeHexString(getInstance("SHA-256").digest(requirements.getBytes(StandardCharsets.UTF_8)));
+ var tmpDir = Objects.toString(getProperty("java.io.tmpdir"), "/tmp");
+ var venv = Paths.get(tmpDir).resolve(requirementsHash).toAbsolutePath();
+ var requirementsFile = Paths.get(venv + "/requirements.txt");
+
+ var lock = venvLocks.computeIfAbsent(requirementsHash, k -> new ReentrantLock());
+ lock.lock();
+ try {
+ // Create virtual environment if it doesn't exist
+ if (!exists(requirementsFile)) {
+ var venvProcess = new ProcessBuilder(python, "-m", "venv", venv.toString()).start();
+ runProcess(venvProcess, 60_000);
+ createDirectories(venv);
+ writeString(requirementsFile, requirements);
+ }
+ python = venv.resolve("bin/python").toString();
+
+ if (!lastUpdate.containsKey(requirementsHash) || lastUpdate.get(requirementsHash).isBefore(now().minus(UPDATE_COOLDOWN))) {
+ lastUpdate.put(requirementsHash, now());
+ // Mark requirements.txt as accessed to prevent deletion for 24 hours
+ var now = FileTime.from(now());
+ setAttribute(requirementsFile, "lastAccessTime", now);
+ setAttribute(requirementsFile, "lastModifiedTime", now);
+ // Install requirements using pip
+ var pip = venv.resolve("bin/pip").toString();
+ var pipProcess = new ProcessBuilder(pip, "install", "--upgrade", "-r", requirementsFile.toString()).start();
+ runProcess(pipProcess, 300_000);
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+ var scriptProcess = new ProcessBuilder(python, "-c", pythonVmWrapperScript, ""+timeoutMs, api).start();
+ try (OutputStreamWriter writer = new OutputStreamWriter(scriptProcess.getOutputStream(), StandardCharsets.UTF_8)) {
+ writer.write(targetScript);
+ writer.write("\0"); // Null byte delimiter
+ writer.write(inputString);
+ writer.flush();
+ } catch (IOException e) {
+ logger.warn("Script terminated before receiving input.");
+ }
+ return runProcess(scriptProcess, timeoutMs);
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/RunProcess.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/RunProcess.java
new file mode 100644
index 000000000..b35fcf571
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/RunProcess.java
@@ -0,0 +1,83 @@
+package jasper.component.vm;
+
+import jasper.errors.ScriptException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.TimeUnit;
+
+public class RunProcess {
+ private static final Logger logger = LoggerFactory.getLogger(RunProcess.class);
+ // Generous timeout to allow reader threads to drain remaining output after process termination
+ private static final int READER_THREAD_JOIN_TIMEOUT_MS = 5_000;
+
+ public static String runProcess(Process process, int timeoutMs) throws ScriptException {
+ // StringBuffer is thread-safe, so reads stay consistent even if a reader thread outlives its join timeout
+ final var output = new StringBuffer();
+ final var errors = new StringBuffer();
+ var outputThread = Thread.ofVirtual().start(() -> readStream(process.getInputStream(), output, "output"));
+ var errorThread = Thread.ofVirtual().start(() -> readStream(process.getErrorStream(), errors, "error"));
+
+ boolean finished;
+ try {
+ finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ destroyTree(process);
+ joinReaders(outputThread, errorThread);
+ Thread.currentThread().interrupt();
+ throw new ScriptException("Script execution interrupted", ""+errors + output);
+ }
+ destroyTree(process);
+ joinReaders(outputThread, errorThread);
+ if (!finished) {
+ throw new ScriptException("Script execution timed out", ""+errors + output);
+ }
+ var exitCode = process.exitValue();
+ if (exitCode != 0) {
+ throw new ScriptException("Script execution failed with exit code: " + exitCode, ""+errors + output);
+ }
+ return output.toString();
+ }
+
+ private static void joinReaders(Thread outputThread, Thread errorThread) {
+ try {
+ outputThread.join(READER_THREAD_JOIN_TIMEOUT_MS);
+ errorThread.join(READER_THREAD_JOIN_TIMEOUT_MS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private static void readStream(InputStream stream, StringBuffer sink, String name) {
+ try (var reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) {
+ var buffer = new char[8192];
+ int read;
+ while ((read = reader.read(buffer)) != -1) {
+ sink.append(buffer, 0, read);
+ }
+ } catch (IOException e) {
+ logger.error("Error reading {} stream: {}", name, e.getMessage());
+ }
+ }
+
+ private static void destroyTree(Process process) {
+ // Snapshot descendants before killing the parent, since they reparent once it exits
+ var descendants = process.descendants().toList();
+ // Destroy the parent first so it cannot spawn new children
+ process.destroy();
+ descendants.forEach(ProcessHandle::destroy);
+ try {
+ if (!process.waitFor(1, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ }
+ } catch (InterruptedException e) {
+ process.destroyForcibly();
+ Thread.currentThread().interrupt();
+ }
+ descendants.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly);
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/Shell.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/Shell.java
new file mode 100644
index 000000000..260f8fe60
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/component/vm/Shell.java
@@ -0,0 +1,59 @@
+package jasper.component.vm;
+
+import io.micrometer.core.annotation.Timed;
+import jasper.config.Props;
+import jasper.errors.ScriptException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.nio.charset.StandardCharsets;
+
+import static jasper.component.vm.RunProcess.runProcess;
+
+@Component
+public class Shell {
+ private static final Logger logger = LoggerFactory.getLogger(Shell.class);
+
+ @Autowired
+ Props props;
+
+ @Value("http://localhost:${server.port}")
+ String api;
+
+ // language=Sh
+ private final String wrapperScript = """
+set -e
+CURRENT_SHELL=$(basename "$0")
+IFS= read -r -d '' TARGET_SCRIPT
+IFS= read -r -d '' INPUT_STRING
+SCRIPT_DIR=$(mktemp -d)
+trap 'rm -rf "$SCRIPT_DIR"' EXIT
+SCRIPT_FILE="$SCRIPT_DIR/script.sh"
+printf '%s\\n' "$TARGET_SCRIPT" > "$SCRIPT_FILE"
+chmod +x "$SCRIPT_FILE"
+TIMEOUT_SECONDS=$((($1 + 999) / 1000))
+JASPER_API="$2" timeout "$TIMEOUT_SECONDS" "$CURRENT_SHELL" "$SCRIPT_FILE" << EOF
+$INPUT_STRING
+EOF
+ """;
+
+ @Timed("jasper.vm")
+ public String runShellScript(String targetScript, String inputString, int timeoutMs) throws ScriptException, IOException {
+ var process = new ProcessBuilder(props.getShell(), "-c", wrapperScript, props.getShell(), String.valueOf(timeoutMs), api).start();
+ try (var writer = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)) {
+ writer.write(targetScript);
+ writer.write("\0"); // null character as delimiter
+ writer.write(inputString);
+ writer.write("\0"); // both inputs must be null terminated
+ writer.flush();
+ } catch (IOException e) {
+ logger.warn("Script terminated before receiving input.");
+ }
+ return runProcess(process, timeoutMs);
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/AsyncConfiguration.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/AsyncConfiguration.java
new file mode 100644
index 000000000..2b22e2a4d
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/AsyncConfiguration.java
@@ -0,0 +1,61 @@
+package jasper.config;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
+import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.task.TaskExecutionProperties;
+import org.springframework.boot.autoconfigure.task.TaskSchedulingProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.AsyncConfigurer;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.annotation.SchedulingConfigurer;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+import org.springframework.scheduling.config.ScheduledTaskRegistrar;
+
+import java.util.concurrent.ExecutorService;
+
+import static java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor;
+
+@Configuration
+@EnableAsync
+@EnableScheduling
+public class AsyncConfiguration implements AsyncConfigurer, SchedulingConfigurer {
+ private final Logger logger = LoggerFactory.getLogger(AsyncConfiguration.class);
+
+ @Autowired
+ TaskExecutionProperties taskExecutionProperties;
+
+ @Autowired
+ TaskSchedulingProperties taskSchedulingProperties;
+
+ @Bean("taskExecutor")
+ @Override
+ public ExecutorService getAsyncExecutor() {
+ return newVirtualThreadPerTaskExecutor();
+ }
+
+ @Bean("taskScheduler")
+ public ThreadPoolTaskScheduler getSchedulerExecutor() {
+ var scheduler = new ThreadPoolTaskScheduler();
+ scheduler.setPoolSize(taskSchedulingProperties.getPool().getSize());
+ scheduler.setThreadNamePrefix(taskSchedulingProperties.getThreadNamePrefix());
+ scheduler.setWaitForTasksToCompleteOnShutdown(true);
+ scheduler.setAwaitTerminationSeconds(60);
+ scheduler.initialize();
+ return scheduler;
+ }
+
+ @Override
+ public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
+ taskRegistrar.setTaskScheduler(getSchedulerExecutor());
+ }
+
+ @Override
+ public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
+ return new SimpleAsyncUncaughtExceptionHandler();
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/AuthConfig.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/AuthConfig.java
new file mode 100644
index 000000000..73181a1ed
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/AuthConfig.java
@@ -0,0 +1,81 @@
+package jasper.config;
+
+import jasper.component.ConfigCache;
+import jasper.management.SecurityMetersService;
+import jasper.repository.RefRepository;
+import jasper.security.Auth;
+import jasper.security.jwt.TokenProvider;
+import jasper.security.jwt.TokenProviderImpl;
+import jasper.security.jwt.TokenProviderImplDefault;
+import jasper.security.jwt.TokenProviderImplNop;
+import org.apache.hc.client5.http.impl.classic.HttpClients;
+import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory;
+import org.apache.http.conn.ssl.TrustStrategy;
+import org.apache.http.ssl.SSLContexts;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.annotation.Profile;
+import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
+import org.springframework.security.access.hierarchicalroles.RoleHierarchy;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.context.annotation.ApplicationScope;
+
+import javax.net.ssl.HostnameVerifier;
+import java.security.KeyManagementException;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.X509Certificate;
+
+@Configuration
+public class AuthConfig {
+
+ @Bean("authSingleton")
+ @ApplicationScope
+ public Auth authSingleton(Props props, RoleHierarchy roleHierarchy, ConfigCache configs, RefRepository refRepository) {
+ return new Auth(props, roleHierarchy, configs, refRepository);
+ }
+
+ @Bean
+ @Profile("!no-ssl")
+ RestTemplate restTemplate() {
+ return new RestTemplate();
+ }
+
+ @Bean
+ @Profile("no-ssl")
+ RestTemplate restTemplateBypassSSL() throws KeyStoreException, NoSuchAlgorithmException, KeyManagementException {
+ TrustStrategy acceptingTrustStrategy = (X509Certificate[] chain, String authType) -> true;
+ HostnameVerifier hostnameVerifier = (s, sslSession) -> true;
+ var sslContext = SSLContexts.custom().loadTrustMaterial(null, acceptingTrustStrategy).build();
+ var csf = new SSLConnectionSocketFactory(sslContext, hostnameVerifier);
+ var cm = PoolingHttpClientConnectionManagerBuilder.create()
+ .setSSLSocketFactory(csf)
+ .build();
+ var httpClient = HttpClients.custom().setConnectionManager(cm).build();
+ var requestFactory = new HttpComponentsClientHttpRequestFactory();
+ requestFactory.setHttpClient(httpClient);
+ return new RestTemplate(requestFactory);
+ }
+
+ @Primary
+ @Bean
+ @Profile("jwt")
+ TokenProvider tokenProvider(Props props, ConfigCache configs, SecurityMetersService securityMetersService, RestTemplate restTemplate) {
+ return new TokenProviderImpl(props, configs, securityMetersService, restTemplate);
+ }
+
+ @Primary
+ @Bean
+ @ConditionalOnMissingBean
+ TokenProvider fallbackTokenProvider(Props props, ConfigCache configs) {
+ return new TokenProviderImplNop(props, configs);
+ }
+
+ @Bean
+ TokenProviderImplDefault defaultTokenProvider(Props props, ConfigCache configs) {
+ return new TokenProviderImplDefault(props, configs);
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/BulkheadConfiguration.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/BulkheadConfiguration.java
new file mode 100644
index 000000000..991f3837a
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/BulkheadConfiguration.java
@@ -0,0 +1,89 @@
+package jasper.config;
+
+import io.github.resilience4j.bulkhead.Bulkhead;
+import io.github.resilience4j.bulkhead.BulkheadConfig;
+import io.github.resilience4j.bulkhead.BulkheadRegistry;
+import jasper.component.ConfigCache;
+import jasper.service.dto.TemplateDto;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.integration.annotation.ServiceActivator;
+import org.springframework.messaging.Message;
+
+import static java.time.Duration.ofMinutes;
+import static java.time.Duration.ofSeconds;
+import static org.apache.commons.lang3.StringUtils.isBlank;
+
+@Configuration
+public class BulkheadConfiguration {
+ private static final Logger logger = LoggerFactory.getLogger(BulkheadConfiguration.class);
+
+ @Autowired
+ ConfigCache configs;
+
+ @Autowired
+ BulkheadRegistry registry;
+
+ @Bean
+ public Bulkhead httpBulkhead() {
+ return registry.bulkhead("http", BulkheadConfig.custom()
+ .maxConcurrentCalls(configs.root().getMaxConcurrentRequests())
+ .maxWaitDuration(ofSeconds(0))
+ .build());
+ }
+
+ @Bean
+ public Bulkhead scriptBulkhead() {
+ return registry.bulkhead("script", BulkheadConfig.custom()
+ .maxConcurrentCalls(configs.root().getMaxConcurrentScripts())
+ .maxWaitDuration(ofSeconds(60))
+ .build());
+ }
+
+ @Bean
+ public Bulkhead replBulkhead() {
+ return registry.bulkhead("repl", BulkheadConfig.custom()
+ .maxConcurrentCalls(configs.root().getMaxConcurrentReplication())
+ .maxWaitDuration(ofMinutes(15))
+ .build());
+ }
+
+ @Bean
+ public Bulkhead fetchBulkhead() {
+ return registry.bulkhead("fetch", BulkheadConfig.custom()
+ .maxConcurrentCalls(configs.root().getMaxConcurrentFetch())
+ .maxWaitDuration(ofMinutes(5))
+ .build());
+ }
+
+ @Bean
+ public Bulkhead recyclerBulkhead() {
+ return registry.bulkhead("recycler", BulkheadConfig.custom()
+ .maxConcurrentCalls(1)
+ .maxWaitDuration(ofMinutes(0))
+ .build());
+ }
+
+ @ServiceActivator(inputChannel = "templateRxChannel")
+ public void handleTemplateUpdate(Message message) {
+ var template = message.getPayload();
+ if (isBlank(template.getTag())) return;
+ if (template.getTag().startsWith("_config/server")) {
+ logger.debug("Server config template updated, updating bulkhead configurations");
+ updateBulkheadConfig(httpBulkhead(), configs.root().getMaxConcurrentRequests());
+ updateBulkheadConfig(scriptBulkhead(), configs.root().getMaxConcurrentScripts());
+ updateBulkheadConfig(replBulkhead(), configs.root().getMaxConcurrentReplication());
+ updateBulkheadConfig(fetchBulkhead(), configs.root().getMaxConcurrentFetch());
+ }
+ }
+
+ public static void updateBulkheadConfig(Bulkhead bulkhead, int limit) {
+ bulkhead.changeConfig(BulkheadConfig
+ .from(bulkhead.getBulkheadConfig())
+ .maxConcurrentCalls(limit)
+ .build());
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/CacheConfig.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/CacheConfig.java
new file mode 100644
index 000000000..65b380b02
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/CacheConfig.java
@@ -0,0 +1,105 @@
+package jasper.config;
+
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.springframework.cache.caffeine.CaffeineCacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+
+import java.util.concurrent.TimeUnit;
+
+@Profile("!test & !no-cache")
+@Configuration
+public class CacheConfig {
+
+ @Bean
+ public CaffeineCacheManager cacheManager() {
+ var cacheManager = new CaffeineCacheManager();
+ cacheManager.registerCustomCache("oembed-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.HOURS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("config-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("user-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(15, TimeUnit.MINUTES)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("user-dto-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(15, TimeUnit.MINUTES)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("user-dto-page-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(15, TimeUnit.MINUTES)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("external-user-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(15, TimeUnit.MINUTES)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("plugin-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("plugin-config-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("plugin-dto-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("plugin-dto-page-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("template-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("template-config-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("template-cache-wrapped", Caffeine.newBuilder()
+ .maximumSize(1)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("template-schemas-cache", Caffeine.newBuilder()
+ .maximumSize(1)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("template-defaults-cache", Caffeine.newBuilder()
+ .maximumSize(1)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("template-dto-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ cacheManager.registerCustomCache("template-dto-page-cache", Caffeine.newBuilder()
+ .maximumSize(10_000)
+ .expireAfterAccess(1, TimeUnit.DAYS)
+ .recordStats()
+ .build());
+ return cacheManager;
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/Config.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/Config.java
new file mode 100644
index 000000000..4f29281a6
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/Config.java
@@ -0,0 +1,401 @@
+package jasper.config;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import jasper.domain.proj.HasTags;
+import jasper.repository.spec.QualifiedTag;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import lombok.With;
+
+import java.io.Serializable;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+import static jasper.domain.proj.HasOrigin.nesting;
+import static jasper.domain.proj.HasTags.hasCapturingTag;
+import static jasper.domain.proj.Tag.matchesTag;
+import static jasper.repository.spec.QualifiedTag.selector;
+import static jasper.repository.spec.QualifiedTag.tagOriginList;
+import static jasper.repository.spec.QualifiedTag.tagOriginSelector;
+import static java.lang.Math.min;
+import static java.util.Comparator.comparingInt;
+import static java.util.stream.Collectors.toMap;
+import static org.apache.commons.collections4.CollectionUtils.isNotEmpty;
+import static org.apache.commons.lang3.StringUtils.isBlank;
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+public interface Config {
+ /**
+ * Root Config installed to _config/server template.
+ * Template will be created with these default values if it does not exist.
+ */
+ @Getter
+ @Setter
+ @Builder
+ @With
+ @AllArgsConstructor
+ @NoArgsConstructor
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ class ServerConfig implements Serializable {
+ @Builder.Default
+ private String emailHost = "jasper.local";
+ @Builder.Default
+ private int maxSources = 1000;
+ @Builder.Default
+ private List modSeals = List.of("seal", "+seal", "_seal", "_moderated");
+ @Builder.Default
+ private List editorSeals = List.of("plugin/qc");
+ /**
+ * Whitelist origins to be allowed web access.
+ */
+ @Builder.Default
+ private List webOrigins = List.of("");
+ @JsonIgnore
+ @Builder.Default
+ private List _webOriginsParsed = null;
+ @JsonIgnore
+ public List webOriginsParsed() {
+ if (webOrigins == null) return null;
+ if (_webOriginsParsed == null) _webOriginsParsed = tagOriginList(webOrigins);
+ return _webOriginsParsed;
+ }
+ @JsonIgnore
+ public boolean web(String origin) {
+ if (webOriginsParsed() == null) return false;
+ var target = selector(origin);
+ return webOriginsParsed().stream().anyMatch(s -> s.captures(target));
+ }
+ @Builder.Default
+ private int maxReplEntityBatch = 500;
+ /**
+ * Whitelist origins to be allowed to open SSH tunnels.
+ */
+ @Builder.Default
+ private List sshOrigins = List.of("");
+ @JsonIgnore
+ @Builder.Default
+ private List _sshOriginsParsed = null;
+ @JsonIgnore
+ public List sshOriginsParsed() {
+ if (sshOrigins == null) return null;
+ if (_sshOriginsParsed == null) _sshOriginsParsed = tagOriginList(sshOrigins);
+ return _sshOriginsParsed;
+ }
+ @JsonIgnore
+ public boolean ssh(String origin) {
+ if (sshOriginsParsed() == null) return false;
+ var target = selector(origin);
+ return sshOriginsParsed().stream().anyMatch(s -> s.captures(target) && nesting(origin) == nesting(s.origin));
+ }
+ @Builder.Default
+ private int maxPushEntityBatch = 5000;
+ @Builder.Default
+ private int maxPullEntityBatch = 5000;
+ /**
+ * Whitelist selectors to run scripts on. No origin wildcards.
+ */
+ @Builder.Default
+ private List scriptSelectors = List.of("");
+ @JsonIgnore
+ @Builder.Default
+ private List _scriptSelectorsParsed = null;
+ @JsonIgnore
+ public List scriptSelectorsParsed() {
+ if (scriptSelectors == null) return null;
+ if (_scriptSelectorsParsed == null) _scriptSelectorsParsed = tagOriginList(scriptSelectors);
+ return _scriptSelectorsParsed;
+ }
+ @JsonIgnore
+ public boolean script(String plugin) {
+ if (scriptSelectorsParsed() == null) return false;
+ return scriptSelectorsParsed().stream().anyMatch(s -> s.captures(tagOriginSelector(plugin + s.origin)));
+ }
+ @JsonIgnore
+ public boolean script(String plugin, String origin) {
+ if (scriptSelectorsParsed() == null) return false;
+ var target = tagOriginSelector(plugin + origin);
+ return scriptSelectorsParsed().stream().anyMatch(s -> s.captures(target) && nesting(origin) == nesting(s.origin));
+ }
+ @JsonIgnore
+ public boolean script(String plugin, HasTags ref) {
+ if (ref == null) return false;
+ if (ref.getTags() == null) return false;
+ if (scriptSelectorsParsed() == null) return false;
+ var origin = isBlank(ref.getOrigin()) ? "@" : ref.getOrigin();
+ var filtered = ref.getTags().stream().filter(t -> matchesTag(plugin, t)).toList();
+ return scriptSelectorsParsed().stream().anyMatch(s -> hasCapturingTag(filtered, origin, s) && nesting(ref.getOrigin()) == nesting(s.origin));
+ }
+ @JsonIgnore
+ public List scriptOrigins(String plugin) {
+ if (scriptSelectorsParsed() == null) return List.of();
+ return scriptSelectorsParsed().stream().filter(s -> s.captures(tagOriginSelector(plugin + s.origin))).map(s -> s.origin).toList();
+ }
+ /**
+ * Whitelist script SHA-256 hashes allowed to run. Allows any scripts if empty.
+ */
+ @Builder.Default
+ private List scriptWhitelist = null;
+ /**
+ * Whitelist domains to be allowed to fetch from.
+ */
+ @Builder.Default
+ private List hostWhitelist = null;
+ /**
+ * Blacklist domains to be allowed to fetch from. Takes precedence over domain whitelist.
+ */
+ @Builder.Default
+ private List hostBlacklist = List.of("*.local");
+ /**
+ * Maximum concurrent script executions. Default 100_000.
+ */
+ @Builder.Default
+ private int maxConcurrentScripts = 100_000;
+ /**
+ * Maximum concurrent replication push/pull operations. Default 3.
+ */
+ @Builder.Default
+ private int maxConcurrentReplication = 3;
+ /**
+ * Maximum HTTP requests per origin every 500 nanoseconds. Default 50.
+ */
+ @Builder.Default
+ private int maxRequests = 50;
+ /**
+ * Global maximum concurrent HTTP requests (across all origins). Default 500.
+ */
+ @Builder.Default
+ private int maxConcurrentRequests = 500;
+ /**
+ * Maximum concurrent fetch operations (scraping). Default 10.
+ */
+ @Builder.Default
+ private int maxConcurrentFetch = 10;
+
+ public ServerConfig wrap(Props props) {
+ var wrapped = this;
+ var server = props.getOverride().getServer();
+ if (isNotBlank(server.getEmailHost())) wrapped = wrapped.withEmailHost(server.getEmailHost());
+ if (server.getMaxSources() != null) wrapped = wrapped.withMaxSources(server.getMaxSources());
+ if (isNotEmpty(server.getModSeals())) wrapped = wrapped.withModSeals(server.getModSeals());
+ if (isNotEmpty(server.getEditorSeals())) wrapped = wrapped.withEditorSeals(server.getEditorSeals());
+ if (isNotEmpty(server.getWebOrigins())) wrapped = wrapped.withWebOrigins(server.getWebOrigins());
+ if (server.getMaxReplEntityBatch() != null) wrapped = wrapped.withMaxReplEntityBatch(server.getMaxReplEntityBatch());
+ if (isNotEmpty(server.getSshOrigins())) wrapped = wrapped.withSshOrigins(server.getSshOrigins());
+ if (server.getMaxPushEntityBatch() != null) wrapped = wrapped.withMaxPushEntityBatch(server.getMaxPushEntityBatch());
+ if (server.getMaxPullEntityBatch() != null) wrapped = wrapped.withMaxPullEntityBatch(server.getMaxPullEntityBatch());
+ if (isNotEmpty(server.getScriptSelectors())) wrapped = wrapped.withScriptSelectors(server.getScriptSelectors());
+ if (isNotEmpty(server.getScriptWhitelist())) wrapped = wrapped.withScriptWhitelist(server.getScriptWhitelist());
+ if (isNotEmpty(server.getHostWhitelist())) wrapped = wrapped.withHostWhitelist(server.getHostWhitelist());
+ if (isNotEmpty(server.getHostBlacklist())) wrapped = wrapped.withHostBlacklist(server.getHostBlacklist());
+ if (server.getMaxRequests() != null) wrapped = wrapped.withMaxRequests(server.getMaxRequests());
+ if (server.getMaxConcurrentRequests() != null) wrapped = wrapped.withMaxConcurrentRequests(server.getMaxConcurrentRequests());
+ if (server.getMaxConcurrentScripts() != null) wrapped = wrapped.withMaxConcurrentScripts(server.getMaxConcurrentScripts());
+ if (server.getMaxConcurrentReplication() != null) wrapped = wrapped.withMaxConcurrentReplication(server.getMaxConcurrentReplication());
+ if (server.getMaxConcurrentFetch() != null) wrapped = wrapped.withMaxConcurrentFetch(server.getMaxConcurrentFetch());
+ return wrapped;
+ }
+
+ public static ServerConfigBuilder builderFor(String origin) {
+ return ServerConfig.builder()
+ .webOrigins(List.of(origin))
+ .sshOrigins(List.of(origin))
+ .scriptSelectors(List.of(isBlank(origin) ? "" : origin));
+ }
+ }
+
+ /**
+ * Tenant Config installed to _config/security template in each tenant.
+ */
+ @Getter
+ @Setter
+ @With
+ @AllArgsConstructor
+ @NoArgsConstructor
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ class SecurityConfig implements Serializable {
+ /**
+ * Authentication mode (jwt or jwks).
+ */
+ private String mode = "";
+ /**
+ * Client ID for OAuth2/JWT authentication.
+ */
+ private String clientId = "";
+ /**
+ * Base64 encoded secret for JWT validation.
+ */
+ private String base64Secret = "";
+ /**
+ * Plain text secret for JWT validation (alternative to base64Secret).
+ */
+ private String secret = "";
+ /**
+ * URI to JWKS endpoint for token validation.
+ */
+ private String jwksUri = "";
+ /**
+ * OAuth2 token endpoint.
+ */
+ private String tokenEndpoint = "";
+ /**
+ * SCIM endpoint for user management.
+ */
+ private String scimEndpoint = "";
+ /**
+ * JWT claim to use as the username.
+ */
+ private String usernameClaim = "sub";
+ /**
+ * Enable external ID matching for users.
+ */
+ private boolean externalId = false;
+ /**
+ * Include email domain in username.
+ */
+ private boolean emailDomainInUsername = false;
+ /**
+ * Root email domain for the server.
+ */
+ private String rootEmailDomain = "";
+ /**
+ * JWT claim for verified email status.
+ */
+ private String verifiedEmailClaim = "verified_email";
+ /**
+ * JWT claim for user authorities/roles.
+ */
+ private String authoritiesClaim = "auth";
+ /**
+ * JWT claim for read access tags.
+ */
+ private String readAccessClaim = "readAccess";
+ /**
+ * JWT claim for write access tags.
+ */
+ private String writeAccessClaim = "writeAccess";
+ /**
+ * JWT claim for tag read access.
+ */
+ private String tagReadAccessClaim = "tagReadAccess";
+ /**
+ * JWT claim for tag write access.
+ */
+ private String tagWriteAccessClaim = "tagWriteAccess";
+ /**
+ * Minimum role for basic access.
+ */
+ private String minRole = "ROLE_ANONYMOUS";
+ /**
+ * Minimum role for writing.
+ */
+ private String minWriteRole = "ROLE_VIEWER";
+ /**
+ * Minimum role for fetching external resources.
+ */
+ private String minFetchRole = "ROLE_USER";
+ /**
+ * Minimum role for admin config.
+ */
+ private String minConfigRole = "ROLE_ADMIN";
+ /**
+ * Minimum role for downloading backups.
+ * Backups may contain private data or private SSH keys, so they are extremely sensitive.
+ */
+ private String minReadBackupsRole = "ROLE_ADMIN";
+ /**
+ * Default role given to every user.
+ */
+ private String defaultRole = "ROLE_ANONYMOUS";
+ /**
+ * Default user tag given to every logged out user.
+ */
+ private String defaultUser = "";
+ /**
+ * Default read access tags for all users.
+ */
+ private List defaultReadAccess;
+ /**
+ * Default write access tags for all users.
+ */
+ private List defaultWriteAccess;
+ /**
+ * Default tag read access tags for all users.
+ */
+ private List defaultTagReadAccess;
+ /**
+ * Default tag write access tags for all users.
+ */
+ private List defaultTagWriteAccess;
+ /**
+ * Maximum HTTP requests per origin every 500 nanoseconds. Default 50.
+ */
+ private int maxRequests = 50;
+ /**
+ * Maximum concurrent script executions per origin. Default 5.
+ */
+ private int maxConcurrentScripts = 5;
+ /**
+ * Per-origin script execution limits. Map of origin selector patterns (origin, or tag+origin) to max concurrent value.
+ * No origin wildcards.
+ * Example: {"@myorg": 20, "+plugin/delta@myorg": 15, "_plugin/delta": 5}
+ * If more than one matches, the smallest limit is chosen.
+ */
+ private Map scriptLimits = Map.of();
+ @JsonIgnore
+ private Map _scriptLimitsParsed = null;
+ @JsonIgnore
+ public Map scriptLimitsParsed() {
+ if (scriptLimits == null) return null;
+ if (_scriptLimitsParsed == null) _scriptLimitsParsed = scriptLimits.entrySet().stream().collect(toMap(e -> tagOriginSelector(e.getKey()), Map.Entry::getValue));
+ return _scriptLimitsParsed;
+ }
+ @JsonIgnore
+ public Integer scriptLimit(String plugin) {
+ if (scriptLimitsParsed() == null) return maxConcurrentScripts;
+ return min(maxConcurrentScripts, scriptLimitsParsed().entrySet().stream()
+ .filter(e -> e.getKey().captures(tagOriginSelector(plugin + e.getKey().origin)))
+ .min(comparingInt(Map.Entry::getValue))
+ .map(Map.Entry::getValue)
+ .orElse(maxConcurrentScripts));
+ }
+ @JsonIgnore
+ public Integer scriptLimit(String plugin, String origin) {
+ if (scriptLimitsParsed() == null) return maxConcurrentScripts;
+ return min(maxConcurrentScripts, scriptLimitsParsed().entrySet().stream()
+ .filter(e -> e.getKey().captures(tagOriginSelector(plugin + origin)))
+ .min(comparingInt(Map.Entry::getValue))
+ .map(Map.Entry::getValue)
+ .orElse(maxConcurrentScripts));
+ }
+
+ public byte[] getSecretBytes() {
+ if (isNotBlank(secret)) return secret.getBytes();
+ return Base64.getDecoder().decode(base64Secret);
+ }
+
+ public SecurityConfig wrap(Props props) {
+ var wrapped = this;
+ var security = props.getOverride().getSecurity();
+ if (isNotBlank(security.getMode())) wrapped = wrapped.withMode(security.getMode());
+ if (isNotBlank(security.getClientId())) wrapped = wrapped.withClientId(security.getClientId());
+ if (isNotBlank(security.getSecret()) || isNotBlank(security.getBase64Secret())) {
+ wrapped = wrapped.withBase64Secret(security.getBase64Secret());
+ wrapped = wrapped.withSecret(security.getSecret());
+ }
+ if (isNotBlank(security.getJwksUri())) wrapped = wrapped.withJwksUri(security.getJwksUri());
+ if (isNotBlank(security.getUsernameClaim())) wrapped = wrapped.withUsernameClaim(security.getUsernameClaim());
+ if (!"unset".equals(security.getVerifiedEmailClaim())) wrapped = wrapped.withVerifiedEmailClaim(security.getVerifiedEmailClaim());
+ if (isNotBlank(security.getDefaultUser())) wrapped = wrapped.withDefaultUser(security.getDefaultUser());
+ if (isNotBlank(security.getTokenEndpoint())) wrapped = wrapped.withTokenEndpoint(security.getTokenEndpoint());
+ if (isNotBlank(security.getScimEndpoint())) wrapped = wrapped.withScimEndpoint(security.getScimEndpoint());
+ if (security.getMaxRequests() != null) wrapped = wrapped.withMaxRequests(security.getMaxRequests());
+ if (security.getMaxConcurrentScripts() != null) wrapped = wrapped.withMaxConcurrentScripts(security.getMaxConcurrentScripts());
+ return wrapped;
+ }
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/Constants.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/Constants.java
new file mode 100644
index 000000000..021faf244
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/Constants.java
@@ -0,0 +1,11 @@
+package jasper.config;
+
+/**
+ * Application constants.
+ */
+public final class Constants {
+
+ public static final String SYSTEM = "system";
+
+ private Constants() {}
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/DatabaseConfiguration.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/DatabaseConfiguration.java
new file mode 100644
index 000000000..9317ac118
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/DatabaseConfiguration.java
@@ -0,0 +1,12 @@
+package jasper.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.transaction.annotation.EnableTransactionManagement;
+
+@Configuration
+@EnableJpaRepositories({ "jasper.repository" })
+@EnableJpaAuditing(auditorAwareRef = "springSecurityAuditorAware")
+@EnableTransactionManagement
+public class DatabaseConfiguration { }
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/DateTimeFormatConfiguration.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/DateTimeFormatConfiguration.java
new file mode 100644
index 000000000..1374256ea
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/DateTimeFormatConfiguration.java
@@ -0,0 +1,20 @@
+package jasper.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.format.FormatterRegistry;
+import org.springframework.format.datetime.standard.DateTimeFormatterRegistrar;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Configure the converters to use the ISO format for dates by default.
+ */
+@Configuration
+public class DateTimeFormatConfiguration implements WebMvcConfigurer {
+
+ @Override
+ public void addFormatters(FormatterRegistry registry) {
+ DateTimeFormatterRegistrar registrar = new DateTimeFormatterRegistrar();
+ registrar.setUseIsoFormat(true);
+ registrar.registerFormatters(registry);
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/FeignConfiguration.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/FeignConfiguration.java
new file mode 100644
index 000000000..08ace11e1
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/FeignConfiguration.java
@@ -0,0 +1,32 @@
+package jasper.config;
+
+import jasper.component.HttpClientFactory;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.cloud.openfeign.FeignClientsConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+
+@Configuration
+@EnableFeignClients(basePackages = "jasper")
+@Import(FeignClientsConfiguration.class)
+public class FeignConfiguration {
+
+ /**
+ * Set the Feign specific log level to log client REST requests.
+ */
+ @Bean
+ feign.Logger.Level feignLoggerLevel() {
+ return feign.Logger.Level.BASIC;
+ }
+
+ @Bean
+ public feign.Contract feignContract() {
+ return new feign.Contract.Default();
+ }
+
+ @Bean
+ public feign.httpclient.ApacheHttpClient feignHttpClient(HttpClientFactory factory) {
+ return new feign.httpclient.ApacheHttpClient(factory.getSerialClient());
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/IntegrationConfig.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/IntegrationConfig.java
new file mode 100644
index 000000000..6f5d8b599
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/IntegrationConfig.java
@@ -0,0 +1,93 @@
+package jasper.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.integration.channel.DirectChannel;
+import org.springframework.integration.channel.PublishSubscribeChannel;
+import org.springframework.integration.config.EnableIntegration;
+import org.springframework.messaging.MessageChannel;
+
+@Configuration
+@EnableIntegration
+public class IntegrationConfig {
+
+ @Bean
+ public MessageChannel cursorTxChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel cursorRxChannel() {
+ return new PublishSubscribeChannel();
+ }
+
+ @Bean
+ public MessageChannel refTxChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel refRxChannel() {
+ return new PublishSubscribeChannel();
+ }
+
+ @Bean
+ public MessageChannel tagTxChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel tagRxChannel() {
+ return new PublishSubscribeChannel();
+ }
+
+ @Bean
+ public MessageChannel responseTxChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel responseRxChannel() {
+ return new PublishSubscribeChannel();
+ }
+
+ @Bean
+ public MessageChannel userTxChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel userRxChannel() {
+ return new PublishSubscribeChannel();
+ }
+
+ @Bean
+ public MessageChannel extTxChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel extRxChannel() {
+ return new PublishSubscribeChannel();
+ }
+
+ @Bean
+ public MessageChannel pluginTxChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel pluginRxChannel() {
+ return new PublishSubscribeChannel();
+ }
+
+ @Bean
+ public MessageChannel templateTxChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel templateRxChannel() {
+ return new PublishSubscribeChannel();
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/JacksonConfiguration.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/JacksonConfiguration.java
new file mode 100644
index 000000000..3c297c82a
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/JacksonConfiguration.java
@@ -0,0 +1,95 @@
+package jasper.config;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.json.JsonReadFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.fasterxml.jackson.datatype.hibernate7.Hibernate7Module;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.jsontypedef.jtd.Validator;
+import org.springframework.boot.jackson2.autoconfigure.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+import org.zalando.problem.violations.ConstraintViolationProblemModule;
+
+@Configuration
+public class JacksonConfiguration {
+ static ObjectMapper om = null;
+
+ public static ObjectMapper om() {
+ assert om != null;
+ return om;
+ }
+
+ public static String dump(Object any) {
+ try {
+ return om().writeValueAsString(any);
+ } catch (JsonProcessingException e) {
+ e.printStackTrace();
+ return "{error}" + any.getClass().getName();
+ }
+ }
+
+ @Bean
+ @Primary
+ ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
+ return builder.createXmlMapper(false).build();
+ }
+
+ @Bean("yamlMapper")
+ public ObjectMapper yamlMapper(Jackson2ObjectMapperBuilder builder) {
+ return builder
+ .createXmlMapper(false)
+ .factory(new YAMLFactory())
+ .build();
+ }
+
+ @Bean
+ public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
+ return builder -> builder.featuresToEnable(
+ JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature(),
+ JsonReadFeature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER.mappedFeature()
+ );
+ }
+
+ /**
+ * Support for Java date and time API.
+ * @return the corresponding Jackson module.
+ */
+ @Bean
+ public JavaTimeModule javaTimeModule() {
+ return new JavaTimeModule();
+ }
+
+ @Bean
+ public Jdk8Module jdk8TimeModule() {
+ return new Jdk8Module();
+ }
+
+ /*
+ * Support for Hibernate types in Jackson.
+ */
+ @Bean
+ public Hibernate7Module hibernate7Module() {
+ return new Hibernate7Module();
+ }
+
+ /*
+ * Module for serialization/deserialization of ConstraintViolationProblem.
+ */
+ @Bean
+ public ConstraintViolationProblemModule constraintViolationProblemModule() {
+ return new ConstraintViolationProblemModule();
+ }
+
+ @Bean
+ public Validator validator() {
+ var validator = new Validator();
+ validator.setMaxDepth(32);
+ validator.setMaxErrors(5);
+ return validator;
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/KubernetesConfig.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/KubernetesConfig.java
new file mode 100644
index 000000000..810ade608
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/KubernetesConfig.java
@@ -0,0 +1,10 @@
+package jasper.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+
+@Profile("kubernetes")
+@Configuration
+public class KubernetesConfig {
+
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/LocaleConfiguration.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/LocaleConfiguration.java
new file mode 100644
index 000000000..c6e4b086d
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/LocaleConfiguration.java
@@ -0,0 +1,17 @@
+package jasper.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
+
+@Configuration
+public class LocaleConfiguration implements WebMvcConfigurer {
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
+ localeChangeInterceptor.setParamName("language");
+ registry.addInterceptor(localeChangeInterceptor);
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/LoggingAspectConfiguration.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/LoggingAspectConfiguration.java
new file mode 100644
index 000000000..0aa3346ff
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/LoggingAspectConfiguration.java
@@ -0,0 +1,19 @@
+package jasper.config;
+
+import jasper.aop.logging.LoggingAspect;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.EnableAspectJAutoProxy;
+import org.springframework.context.annotation.Profile;
+import org.springframework.core.env.Environment;
+
+@Configuration
+@EnableAspectJAutoProxy
+public class LoggingAspectConfiguration {
+
+ @Bean
+ @Profile("dev")
+ public LoggingAspect loggingAspect(Environment env) {
+ return new LoggingAspect(env);
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/MetricsConfig.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/MetricsConfig.java
new file mode 100644
index 000000000..6b4a0034d
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/MetricsConfig.java
@@ -0,0 +1,67 @@
+package jasper.config;
+
+import io.micrometer.core.aop.CountedAspect;
+import io.micrometer.core.aop.TimedAspect;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.Tag;
+import io.micrometer.core.instrument.Tags;
+import jasper.repository.spec.QualifiedTag;
+import jasper.security.Auth;
+import org.apache.logging.log4j.util.Strings;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.support.ScopeNotActiveException;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.core.authority.AuthorityUtils;
+
+import java.util.List;
+import java.util.Optional;
+
+@Configuration
+public class MetricsConfig {
+
+ @Autowired
+ Props props;
+
+ @Autowired
+ Auth auth;
+
+ @Bean
+ CountedAspect countedAspect(MeterRegistry registry) {
+ return new CountedAspect(registry, this::tagFactory);
+ }
+
+ @Bean
+ TimedAspect timedAspect(MeterRegistry registry) {
+ return new TimedAspect(registry, this::tagFactory);
+ }
+
+ private Iterable tagFactory(ProceedingJoinPoint pjp) {
+ return Tags.of(
+ "class", pjp.getStaticPart().getSignature().getDeclaringTypeName(),
+ "method", pjp.getStaticPart().getSignature().getName(),
+ "debug", ""+props.isDebug())
+ .and(getUserTags());
+ }
+
+ private Iterable getUserTags() {
+ try {
+ var userTag = Optional.ofNullable(auth.getUserTag()).map(QualifiedTag::toString);
+ var roles = auth.getAuthentication() != null ? AuthorityUtils.authorityListToSet(auth.getAuthentication().getAuthorities()) : List.of();
+ return Tags.of(
+ "scope", "request",
+ "userTag", userTag.orElse(""),
+ "roles", Strings.join(roles, ','),
+ "origin", auth.getOrigin()
+ );
+ } catch (ScopeNotActiveException e) {
+ return Tags.of(
+ "scope", "system",
+ "userTag", "",
+ "roles", "",
+ "origin", ""
+ );
+ }
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/ObjectMapperConfig.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/ObjectMapperConfig.java
new file mode 100644
index 000000000..1eb3f3174
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/ObjectMapperConfig.java
@@ -0,0 +1,18 @@
+package jasper.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.PostConstruct;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class ObjectMapperConfig {
+
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @PostConstruct
+ void initStatic() {
+ JacksonConfiguration.om = objectMapper;
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/OpenApiConfiguration.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/OpenApiConfiguration.java
new file mode 100644
index 000000000..1fd1b2187
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/OpenApiConfiguration.java
@@ -0,0 +1,34 @@
+package jasper.config;
+
+import org.springdoc.core.models.GroupedOpenApi;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Profile;
+
+@Configuration
+@Profile("api-docs")
+public class OpenApiConfiguration {
+
+ public static final String API_FIRST_PACKAGE = "jasper.web.api";
+
+ @Bean
+ public GroupedOpenApi openApi(
+ Props props
+ ) {
+ Props.ApiDocs properties = props.getApiDocs();
+ return GroupedOpenApi
+ .builder()
+ .displayName(properties.getTitle())
+ .group("jasper")
+ .packagesToScan("jasper.web.rest")
+ .pathsToMatch(properties.getDefaultIncludePattern())
+ .addOpenApiCustomizer(openApi -> {
+ openApi.getInfo().setTitle(properties.getTitle());
+ openApi.getInfo().setDescription(properties.getDescription());
+ openApi.getInfo().setVersion(properties.getVersion());
+ openApi.getInfo().setLicense(properties.getLicense());
+ properties.getServers().forEach(openApi::addServersItem);
+ })
+ .build();
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/PostgreSQLDialect.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/PostgreSQLDialect.java
new file mode 100644
index 000000000..82a9fb7fd
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/PostgreSQLDialect.java
@@ -0,0 +1,45 @@
+package jasper.config;
+
+import org.hibernate.boot.model.FunctionContributions;
+import org.hibernate.dialect.function.StandardSQLFunction;
+import org.hibernate.type.SqlTypes;
+import org.hibernate.type.StandardBasicTypes;
+
+public class PostgreSQLDialect extends org.hibernate.dialect.PostgreSQLDialect {
+ @Override
+ public void initializeFunctionRegistry(FunctionContributions functionContributions) {
+ super.initializeFunctionRegistry(functionContributions);
+ var functionRegistry = functionContributions.getFunctionRegistry();
+ var string = functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(StandardBasicTypes.STRING);
+ var bool = functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(StandardBasicTypes.BOOLEAN);
+ var integer = functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(StandardBasicTypes.INTEGER);
+ var doubleType = functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(StandardBasicTypes.DOUBLE);
+ var jsonb = functionContributions.getTypeConfiguration().getBasicTypeRegistry().resolve(Object.class, SqlTypes.JSON);
+ functionRegistry.register("age", new StandardSQLFunction("age", StandardBasicTypes.DURATION));
+ functionRegistry.registerPattern("jsonb_exists", "jsonb_exists(?1, ?2)", bool);
+ functionRegistry.registerPattern("jsonb_extract_path", "jsonb_extract_path(?1, ?2)", jsonb);
+ functionRegistry.registerPattern("jsonb_object_field", "(?1)->(?2)", jsonb);
+ functionRegistry.registerPattern("jsonb_object_field_text", "(?1)->>(?2)", string);
+ functionRegistry.registerPattern("jsonb_set", "jsonb_set(?1, ?2, ?3, ?4)", jsonb);
+ functionRegistry.registerPattern("jsonb_strip_nulls", "jsonb_strip_nulls(?1)", jsonb);
+ functionRegistry.registerPattern("cast_to_jsonb", "?1::jsonb", jsonb);
+ functionRegistry.registerPattern("jsonb_concat", "jsonb_concat(?1, ?2)", jsonb);
+ functionRegistry.registerPattern("cast_to_int", "(?1)::integer", integer);
+ functionRegistry.registerPattern("cast_to_numeric", "(?1)::numeric", doubleType);
+ functionRegistry.registerPattern("jsonb_array_length", "jsonb_array_length(?1)", integer);
+ functionRegistry.registerPattern("jsonb_array_element_text", "jsonb_array_element_text(?1, ?2)", string);
+ // origin_nesting: returns 0 for blank or '@', otherwise count of '.' + 1
+ functionRegistry.registerPattern("origin_nesting", "CASE WHEN ?1 = '' OR ?1 = '@' THEN 0 ELSE (LENGTH(?1) - LENGTH(REPLACE(?1, '.', '')) + 1) END", integer);
+ // tag_levels: returns 0 for blank tag, otherwise count of '/' + 1
+ functionRegistry.registerPattern("tag_levels", "CASE WHEN ?1 = '' THEN 0 ELSE (LENGTH(?1) - LENGTH(REPLACE(?1, '/', '')) + 1) END", integer);
+ // Vote sorting functions - kept together for consistency
+ functionRegistry.registerPattern("vote_top", "COALESCE((?1->'plugins'->>'plugin/user/vote/up')::int, 0) + COALESCE((?1->'plugins'->>'plugin/user/vote/down')::int, 0)", integer);
+ functionRegistry.registerPattern("vote_score", "COALESCE((?1->'plugins'->>'plugin/user/vote/up')::int, 0) - COALESCE((?1->'plugins'->>'plugin/user/vote/down')::int, 0)", integer);
+ functionRegistry.registerPattern("vote_decay", "floor((3 + COALESCE((?1->'plugins'->>'plugin/user/vote/up')::int, 0) - COALESCE((?1->'plugins'->>'plugin/user/vote/down')::int, 0)) * pow(CASE WHEN 3 + COALESCE((?1->'plugins'->>'plugin/user/vote/up')::int, 0) > COALESCE((?1->'plugins'->>'plugin/user/vote/down')::int, 0) THEN 0.5 ELSE 2 END, EXTRACT('EPOCH' FROM age(?2)) / (4 * 60 * 60)))", doubleType);
+ // Collation function for binary ASCII sorting (+ comes before _)
+ functionRegistry.registerPattern("collate_c", "(?1) COLLATE \"C\"", string);
+ // jsonb_array_append: append a text value to a JSON array
+ functionRegistry.registerPattern("jsonb_array_append", "(?1 || to_jsonb(CAST(?2 AS text)))", jsonb);
+ }
+
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/Props.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/Props.java
new file mode 100644
index 000000000..2d1fe5bfd
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/Props.java
@@ -0,0 +1,434 @@
+package jasper.config;
+
+import io.swagger.v3.oas.models.info.License;
+import io.swagger.v3.oas.models.servers.Server;
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.web.cors.CorsConfiguration;
+
+import java.util.List;
+
+import static jasper.domain.proj.HasOrigin.subOrigin;
+import static java.lang.Integer.parseInt;
+import static org.apache.commons.lang3.ArrayUtils.isNotEmpty;
+import static org.apache.commons.lang3.StringUtils.isBlank;
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+/**
+ * Properties specific to Jasper.
+ *
+ * Properties are configured in the {@code application.yml} file.
+ */
+@Getter
+@Setter
+@ConfigurationProperties(prefix = "jasper", ignoreUnknownFields = false)
+public class Props {
+ /**
+ * Enable debug mode for additional logging.
+ */
+ private boolean debug = false;
+ /**
+ * List of workers to create by origin. Each worker will use the
+ * _config/server/sub-origin file in the local origin to perform certain tasks.
+ * The sub origin for this worker is at the index in the worker name.
+ */
+ private String[] workload;
+ /**
+ * The name of this worker. The number suffix is used as an index
+ * into workload to determine the worker sub origin.
+ */
+ private String worker;
+ /**
+ * Worker sub-origin.
+ */
+ public String getWorkerOrigin() {
+ if (isNotEmpty(workload) && isNotBlank(worker)) {
+ var index = worker.replaceAll("\\D", "");
+ return workload[isBlank(index) ? 0 : parseInt(index)];
+ } else {
+ return "";
+ }
+ }
+ /**
+ * Local origin for this server.
+ */
+ private String localOrigin = "";
+ /**
+ * Computed origin (local + worker).
+ */
+ public String getOrigin() {
+ return subOrigin(getLocalOrigin(), getWorkerOrigin());
+ }
+ /**
+ * Allow pre-authentication of a user via the User-Tag header.
+ */
+ private boolean allowUserTagHeader = false;
+ /**
+ * Allow escalating user role via User-Role header.
+ */
+ private boolean allowUserRoleHeader = false;
+ /**
+ * Allow adding additional user permissions via Read-Access, Write-Access, Tag-Read-Access, and Tag-Write-Access headers.
+ */
+ private boolean allowAuthHeaders = false;
+ /**
+ * Highest role allowed access.
+ */
+ private String maxRole = "ROLE_ADMIN";
+ /**
+ * Minimum role for basic access.
+ */
+ private String minRole = "ROLE_ANONYMOUS";
+ /**
+ * Minimum role for writing.
+ */
+ private String minWriteRole = "ROLE_VIEWER";
+ /**
+ * Minimum role for fetching external resources.
+ */
+ private String minFetchRole = "ROLE_USER";
+ /**
+ * Minimum role for admin config.
+ */
+ private String minConfigRole = "ROLE_ADMIN";
+ /**
+ * Minimum role for downloading backups.
+ * Backups may contain private data or private SSH keys, so they are extremely sensitive.
+ */
+ private String minReadBackupsRole = "ROLE_ADMIN";
+ /**
+ * Default role given to every user.
+ */
+ private String defaultRole = "ROLE_ANONYMOUS";
+ /**
+ * Additional read access qualified tags to apply to all users.
+ */
+ private String[] defaultReadAccess;
+ /**
+ * Additional write access qualified tags to apply to all users.
+ */
+ private String[] defaultWriteAccess;
+ /**
+ * Additional tag read access qualified tags to apply to all users.
+ */
+ private String[] defaultTagReadAccess;
+ /**
+ * Additional tag write access qualified tags to apply to all users.
+ */
+ private String[] defaultTagWriteAccess;
+
+ /**
+ * Maximum number of retry attempts for getting a unique modified date when ingesting a Ref.
+ */
+ private int ingestMaxRetry = 5;
+ /**
+ * Size of buffer in bytes used to cache JSON in RAM before flushing to disk during backup.
+ */
+ private int backupBufferSize = 1000000;
+ /**
+ * Number of entities to restore in each transaction.
+ */
+ private int restoreBatchSize = 500;
+ /**
+ * Number of entities to generate Metadata for in each transaction when backfilling.
+ */
+ private int backfillBatchSize = 100;
+ /**
+ * Number of seconds the server must be idle (no REST API requests) before backfill runs.
+ * Set to 0 to disable idle detection and always run backfill.
+ */
+ private int backfillIdleSec = 0;
+ /**
+ * Number of seconds to throttle clearing the config cache.
+ */
+ private int clearCacheCooldownSec = 2;
+ /**
+ * Number of seconds to throttle pushing after modification.
+ */
+ private int pushCooldownSec = 1;
+
+ /**
+ * Path to the folder to use for storage. Used by the backup system.
+ */
+ private String storage = "/var/lib/jasper";
+ /**
+ * Path to node binary for running javascript deltas.
+ */
+ private String node = "/usr/local/bin/node";
+ /**
+ * Path to python binary for running python scripts.
+ */
+ private String python = "/usr/bin/python";
+ /**
+ * Path to shell binary for running shell scripts.
+ */
+ private String shell = "/usr/bin/bash";
+ /**
+ * HTTP address of an instance where storage is enabled.
+ */
+ private String cacheApi = "";
+
+ /**
+ * K8s namespace to write authorized_keys config map file to.
+ */
+ private String sshConfigNamespace = "default";
+ /**
+ * K8s config map name to write authorized_keys file to.
+ */
+ private String sshConfigMapName = "ssh-authorized-keys";
+ /**
+ * K8s secret name to write the host_key file to.
+ */
+ private String sshSecretName = "ssh-host-key";
+
+ private final Overrides override = new Overrides();
+ private final Http http = new Http();
+ private final Mail mail = new Mail();
+ private final Security security = new Security();
+ private final ApiDocs apiDocs = new ApiDocs();
+ private final CorsConfiguration cors = new CorsConfiguration();
+ private final AuditEvents auditEvents = new AuditEvents();
+
+ @Getter
+ @Setter
+ public static class Overrides {
+ /**
+ * Override any server settings for all origins.
+ */
+ private final ServerOverrides server = new ServerOverrides();
+ /**
+ * Override any security settings for all origins.
+ */
+ private final SecurityOverrides security = new SecurityOverrides();
+ }
+
+ @Getter
+ @Setter
+ public static class ServerOverrides {
+ /**
+ * Override the server email host.
+ */
+ private String emailHost;
+ /**
+ * Override the server max sources.
+ */
+ private Integer maxSources;
+ /**
+ * Override the server mod seals.
+ */
+ private List modSeals;
+ /**
+ * Override the server editor seals.
+ */
+ private List editorSeals;
+ /**
+ * Override the server origins with web access.
+ */
+ private List webOrigins;
+ /**
+ * Override the server maximum batch size for replicate controller.
+ */
+ private Integer maxReplEntityBatch;
+ /**
+ * Override the server origins with SSH access.
+ */
+ private List sshOrigins;
+ /**
+ * Override the server maximum batch size for push replicate.
+ */
+ private Integer maxPushEntityBatch;
+ /**
+ * Override the server maximum batch size for pull replicate.
+ */
+ private Integer maxPullEntityBatch;
+ /**
+ * Override the server tags and origins that can run scripts. No wildcard origins.
+ */
+ private List scriptSelectors;
+ /**
+ * Override the server list of whitelisted script SHA-256 hashes.
+ */
+ private List scriptWhitelist;
+ /**
+ * Override the server list of whitelisted hosts.
+ */
+ private List hostWhitelist;
+ /**
+ * Override the server list of blacklisted hosts.
+ */
+ private List hostBlacklist;
+ /**
+ * Override the server maximum HTTP requests per origin every 500 nanoseconds.
+ */
+ private Integer maxRequests;
+ /**
+ * Override the server global maximum concurrent HTTP requests (across all origins).
+ */
+ private Integer maxConcurrentRequests;
+ /**
+ * Override the server maximum concurrent script executions.
+ */
+ private Integer maxConcurrentScripts;
+ /**
+ * Override the server maximum concurrent replication push/pull operations.
+ */
+ private Integer maxConcurrentReplication;
+ /**
+ * Override the server maximum concurrent fetch operations (scraping).
+ */
+ private Integer maxConcurrentFetch;
+ }
+
+ @Getter
+ @Setter
+ public static class SecurityOverrides {
+ /**
+ * Override the security mode for all origins.
+ */
+ private String mode = "";
+ /**
+ * Override the security clientId for all origins.
+ */
+ private String clientId = "";
+ /**
+ * Override the security base64Secret for all origins.
+ */
+ private String base64Secret = "";
+ /**
+ * Override the security secret for all origins.
+ */
+ private String secret = "";
+ /**
+ * Override the security jwksUri for all origins.
+ */
+ private String jwksUri = "";
+ /**
+ * Override the security usernameClaim for all origins.
+ */
+ private String usernameClaim = "";
+ /**
+ * Override the security verifiedEmailClaim for all origins.
+ */
+ private String verifiedEmailClaim = "unset";
+ /**
+ * Override the security defaultUser for all origins.
+ */
+ private String defaultUser = "";
+ /**
+ * Override the security tokenEndpoint for all origins.
+ */
+ private String tokenEndpoint = "";
+ /**
+ * Override the security scimEndpoint for all origins.
+ */
+ private String scimEndpoint = "";
+ /**
+ * Override the per-origin maximum HTTP requests every 500 nanoseconds. This limit applies to all origins.
+ */
+ private Integer maxRequests;
+ /**
+ * Override the per-origin maximum concurrent script executions. This limit applies to all origins.
+ */
+ private Integer maxConcurrentScripts;
+ }
+
+
+ @Getter
+ @Setter
+ public static class Http {
+ private final Cache cache = new Cache();
+
+ @Getter
+ @Setter
+ public static class Cache {
+ /**
+ * Time to live for HTTP cache in days.
+ */
+ private int timeToLiveInDays = 1461; // 4 years (including leap day)
+ }
+ }
+ @Getter
+ @Setter
+ public static class Mail {
+ /**
+ * Enable mail service.
+ */
+ private boolean enabled = false;
+ /**
+ * From address for outgoing emails.
+ */
+ private String from = "";
+ /**
+ * Base URL for email links.
+ */
+ private String baseUrl = "";
+ }
+
+ @Getter
+ @Setter
+ public static class Security {
+ /**
+ * Content Security Policy header value.
+ */
+ private String contentSecurityPolicy = "default-src 'self'; frame-src 'self' data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:";
+ }
+
+ @Getter
+ @Setter
+ public static class ApiDocs {
+ /**
+ * API documentation title.
+ */
+ private String title = "";
+ /**
+ * API documentation description.
+ */
+ private String description = "";
+ /**
+ * API version.
+ */
+ private String version = "";
+ /**
+ * Terms of service URL.
+ */
+ private String termsOfServiceUrl = "";
+ /**
+ * Contact name for API support.
+ */
+ private String contactName = "";
+ /**
+ * Contact URL for API support.
+ */
+ private String contactUrl = "";
+ /**
+ * Contact email for API support.
+ */
+ private String contactEmail = "";
+ /**
+ * API license information.
+ */
+ private License license;
+ /**
+ * Default include pattern for API docs.
+ */
+ private String defaultIncludePattern = "";
+ /**
+ * Management include pattern for API docs.
+ */
+ private String managementIncludePattern = "";
+ /**
+ * List of API servers.
+ */
+ private List servers;
+ }
+
+ @Getter
+ @Setter
+ public static class AuditEvents {
+ /**
+ * Audit event retention period in days.
+ */
+ private int retentionPeriod = 30;
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/RateLimitConfig.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/RateLimitConfig.java
new file mode 100644
index 000000000..c94b3a7a4
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/RateLimitConfig.java
@@ -0,0 +1,114 @@
+package jasper.config;
+
+import io.github.resilience4j.bulkhead.Bulkhead;
+import io.github.resilience4j.bulkhead.BulkheadFullException;
+import io.github.resilience4j.ratelimiter.RateLimiter;
+import io.github.resilience4j.ratelimiter.RateLimiterConfig;
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletRequest;
+import jakarta.servlet.ServletResponse;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jasper.component.ConfigCache;
+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.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.integration.annotation.ServiceActivator;
+import org.springframework.messaging.Message;
+import org.springframework.web.filter.GenericFilterBean;
+
+import java.io.IOException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ThreadLocalRandom;
+
+import static java.lang.String.format;
+import static java.time.Duration.ofNanos;
+import static org.apache.commons.lang3.StringUtils.isBlank;
+
+@Configuration
+public class RateLimitConfig {
+ private static final Logger logger = LoggerFactory.getLogger(RateLimitConfig.class);
+
+ @Autowired
+ ConfigCache configs;
+
+ @Autowired
+ Auth auth;
+
+ @Autowired
+ Bulkhead httpBulkhead;
+
+ private final ConcurrentHashMap originRateLimiters = new ConcurrentHashMap<>();
+ private RateLimiter getOriginRateLimiter(String origin) {
+ return originRateLimiters.computeIfAbsent(origin, k -> RateLimiter.of("http-" + origin, RateLimiterConfig.custom()
+ .limitForPeriod(configs.security(origin).getMaxRequests())
+ .limitRefreshPeriod(ofNanos(500))
+ .build()));
+ }
+
+ @ServiceActivator(inputChannel = "templateRxChannel")
+ public void handleTemplateUpdate(Message message) {
+ var template = message.getPayload();
+ if (isBlank(template.getTag())) return;
+ if (template.getTag().startsWith("_config/server")) {
+ httpRateLimiter().changeLimitForPeriod(configs.root().getMaxConcurrentRequests());
+ }
+ if (template.getTag().startsWith("_config/security")) {
+ originRateLimiters.forEach((origin, r) -> r.changeLimitForPeriod(configs.security(origin).getMaxRequests()));
+ }
+ }
+
+ @Bean
+ RateLimiter httpRateLimiter() {
+ return RateLimiter.of("http", RateLimiterConfig.custom()
+ .limitForPeriod(configs.root().getMaxConcurrentRequests())
+ .limitRefreshPeriod(ofNanos(500))
+ .build());
+ }
+
+ @Bean
+ public Filter rateLimitInterceptor(RateLimiter httpRateLimiter) {
+ return new GenericFilterBean() {
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+ HttpServletRequest httpRequest = (HttpServletRequest) request;
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
+ var origin = auth.getOrigin();
+ if (!getOriginRateLimiter(origin).acquirePermission()) {
+ RateLimitConfig.logger.debug("{} Rate limit exceeded for origin: {}", origin, httpRequest.getRequestURI());
+ httpResponse.setStatus(429);
+ httpResponse.setHeader("X-RateLimit-Limit", ""+configs.security(origin).getMaxRequests());
+ // Add random jitter from 3.5 to 4.5 seconds to prevent thundering herd
+ httpResponse.setHeader("X-RateLimit-Retry-After", format("%.1f", ThreadLocalRandom.current().nextDouble(3.5, 4.5)));
+ return;
+ }
+ if (!httpRateLimiter.acquirePermission()) {
+ RateLimitConfig.logger.debug("HTTP rate limit exceeded for request: {}", httpRequest.getRequestURI());
+ httpResponse.setStatus(429);
+ // Add random jitter from 3.5 to 4.5 seconds to prevent thundering herd
+ httpResponse.setHeader("X-RateLimit-Retry-After", format("%.1f", ThreadLocalRandom.current().nextDouble(3.5, 4.5)));
+ return;
+ }
+ try {
+ httpBulkhead.executeCheckedSupplier(() -> {
+ chain.doFilter(request, response);
+ return null;
+ });
+ } catch (BulkheadFullException e) {
+ RateLimitConfig.logger.debug("HTTP concurrent limit exceeded for request: {}", httpRequest.getRequestURI());
+ httpResponse.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE); // 503
+ // Add random jitter from 3.5 to 4.5 seconds to prevent thundering herd
+ httpResponse.setHeader("X-RateLimit-Retry-After", format("%.1f", ThreadLocalRandom.current().nextDouble(3.5, 4.5)));
+ } catch (Throwable e) {
+ throw new RuntimeException(e);
+ }
+ }
+ };
+ }
+}
diff --git a/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/RedisConfig.java b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/RedisConfig.java
new file mode 100644
index 000000000..fbeebb3a1
--- /dev/null
+++ b/jdk_25_maven/cs/rest/jasper/src/main/java/jasper/config/RedisConfig.java
@@ -0,0 +1,529 @@
+package jasper.config;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jasper.service.dto.ExtDto;
+import jasper.service.dto.PluginDto;
+import jasper.service.dto.RefDto;
+import jasper.service.dto.TemplateDto;
+import jasper.service.dto.UserDto;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Profile;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
+import org.springframework.integration.channel.DirectChannel;
+import org.springframework.integration.dsl.IntegrationFlow;
+import org.springframework.integration.handler.AbstractMessageHandler;
+import org.springframework.messaging.Message;
+import org.springframework.messaging.MessageChannel;
+import org.springframework.messaging.support.MessageBuilder;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+
+import static jasper.component.Messages.originHeaders;
+import static jasper.component.Messages.refHeaders;
+import static jasper.component.Messages.responseHeaders;
+import static jasper.component.Messages.tagHeaders;
+import static jasper.domain.proj.HasOrigin.formatOrigin;
+import static jasper.domain.proj.HasTags.formatTag;
+import static java.util.Arrays.copyOfRange;
+import static org.springframework.data.redis.listener.PatternTopic.of;
+
+@Profile("redis")
+@Import(DataRedisAutoConfiguration.class)
+@Configuration
+public class RedisConfig {
+ private static final Logger logger = LoggerFactory.getLogger(RedisConfig.class);
+
+ @Autowired
+ ObjectMapper objectMapper;
+
+ @Autowired
+ RedisConnectionFactory redisConnectionFactory;
+
+ @Autowired
+ MessageChannel cursorTxChannel;
+
+ @Autowired
+ MessageChannel cursorRxChannel;
+
+ @Autowired
+ MessageChannel refTxChannel;
+
+ @Autowired
+ MessageChannel refRxChannel;
+
+ @Autowired
+ MessageChannel tagTxChannel;
+
+ @Autowired
+ MessageChannel tagRxChannel;
+
+ @Autowired
+ MessageChannel responseTxChannel;
+
+ @Autowired
+ MessageChannel responseRxChannel;
+
+ @Autowired
+ MessageChannel userTxChannel;
+
+ @Autowired
+ MessageChannel userRxChannel;
+
+ @Autowired
+ MessageChannel extTxChannel;
+
+ @Autowired
+ MessageChannel extRxChannel;
+
+ @Autowired
+ MessageChannel pluginTxChannel;
+
+ @Autowired
+ MessageChannel pluginRxChannel;
+
+ @Autowired
+ MessageChannel templateTxChannel;
+
+ @Autowired
+ MessageChannel templateRxChannel;
+
+ @Bean
+ public MessageChannel cursorRedisChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel refRedisChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel tagRedisChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel responseRedisChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel userRedisChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel extRedisChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel pluginRedisChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public MessageChannel templateRedisChannel() {
+ return new DirectChannel();
+ }
+
+ @Bean
+ public IntegrationFlow redisPublishCursorFlow() {
+ return IntegrationFlow
+ .from(cursorTxChannel)
+ .handle(new CustomPublishingMessageHandler() {
+ @Override
+ protected String getTopic(Message message) {
+ return "cursor/" + formatOrigin(message.getHeaders().get("origin"));
+ }
+
+ @Override
+ protected byte[] getMessage(Message message) {
+ return message.getPayload().toString().getBytes();
+ }
+ })
+ .get();
+ }
+
+ @Bean
+ public IntegrationFlow redisSubscribeCursorFlow() {
+ return IntegrationFlow
+ .from(cursorRedisChannel())
+ .channel(cursorRxChannel)
+ .get();
+ }
+
+ @Bean
+ public RedisMessageListenerContainer redisCursorRxAdapter(RedisConnectionFactory redisConnectionFactory) {
+ var container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(redisConnectionFactory);
+ container.addMessageListener((message, pattern) -> {
+ var cursor = Instant.parse(new String(message.getBody()));
+ var parts = new String(message.getChannel(), StandardCharsets.UTF_8).split("/");
+ var origin = parts[1];
+ cursorRedisChannel().send(MessageBuilder.createMessage(cursor, originHeaders(origin)));
+ }, of("cursor/*"));
+ return container;
+ }
+
+ @Bean
+ public IntegrationFlow redisPublishRefFlow() {
+ return IntegrationFlow
+ .from(refTxChannel)
+ .handle(new CustomPublishingMessageHandler() {
+ @Override
+ protected String getTopic(Message message) {
+ return "ref/" + formatOrigin(message.getHeaders().get("origin")) + "/" + message.getHeaders().get("url");
+ }
+
+ @Override
+ protected byte[] getMessage(Message message) {
+ try {
+ return objectMapper.writeValueAsBytes(message.getPayload());
+ } catch (JsonProcessingException e) {
+ logger.error("Cannot serialize RefDto.");
+ throw new RuntimeException(e);
+ }
+ }
+ })
+ .get();
+ }
+
+ @Bean
+ public IntegrationFlow redisSubscribeRefFlow() {
+ return IntegrationFlow
+ .from(refRedisChannel())
+ .channel(refRxChannel)
+ .get();
+ }
+
+ @Bean
+ public RedisMessageListenerContainer redisRefRxAdapter(RedisConnectionFactory redisConnectionFactory) {
+ var container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(redisConnectionFactory);
+ container.addMessageListener((message, pattern) -> {
+ try {
+ var ref = objectMapper.readValue(message.getBody(), RefDto.class);
+ var parts = new String(message.getChannel(), StandardCharsets.UTF_8).split("/");
+ var origin = parts[1];
+ refRedisChannel().send(MessageBuilder.createMessage(ref, refHeaders(origin, ref)));
+ } catch (IOException e) {
+ logger.error("Error parsing RefDto from redis.");
+ }
+ }, of("ref/*"));
+ return container;
+ }
+
+ @Bean
+ public IntegrationFlow redisPublishTagFlow() {
+ return IntegrationFlow
+ .from(tagTxChannel)
+ .handle(new CustomPublishingMessageHandler() {
+ @Override
+ protected String getTopic(Message message) {
+ return "tag/" + formatOrigin(message.getHeaders().get("origin")) + "/" + formatTag(message.getHeaders().get("tag"));
+ }
+
+ @Override
+ protected byte[] getMessage(Message message) {
+ return message.getPayload().getBytes();
+ }
+ })
+ .get();
+ }
+
+ @Bean
+ public IntegrationFlow redisSubscribeTagFlow() {
+ return IntegrationFlow
+ .from(tagRedisChannel())
+ .channel(tagRxChannel)
+ .get();
+ }
+
+ @Bean
+ public RedisMessageListenerContainer redisTagRxAdapter(RedisConnectionFactory redisConnectionFactory) {
+ var container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(redisConnectionFactory);
+ container.addMessageListener((message, pattern) -> {
+ var fullTag = new String(message.getBody(), StandardCharsets.UTF_8);
+ var parts = new String(message.getChannel(), StandardCharsets.UTF_8).split("/");
+ var origin = parts[1];
+ var tag = String.join("/", copyOfRange(parts, 2, parts.length));
+ tagRedisChannel().send(MessageBuilder.createMessage(fullTag, tagHeaders(origin, tag)));
+ }, of("tag/*"));
+ return container;
+ }
+
+ @Bean
+ public IntegrationFlow redisPublishResponseFlow() {
+ return IntegrationFlow
+ .from(responseTxChannel)
+ .handle(new CustomPublishingMessageHandler() {
+ @Override
+ protected String getTopic(Message message) {
+ return "response/" + formatOrigin(message.getHeaders().get("origin")) + "/" + message.getHeaders().get("response");
+ }
+
+ @Override
+ protected byte[] getMessage(Message message) {
+ return message.getPayload().getBytes();
+ }
+ })
+ .get();
+ }
+
+ @Bean
+ public IntegrationFlow redisSubscribeResponseFlow() {
+ return IntegrationFlow
+ .from(responseRedisChannel())
+ .channel(responseRxChannel)
+ .get();
+ }
+
+ @Bean
+ public RedisMessageListenerContainer redisResponseRxAdapter(RedisConnectionFactory redisConnectionFactory) {
+ var container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(redisConnectionFactory);
+ container.addMessageListener((message, pattern) -> {
+ var response = new String(message.getBody(), StandardCharsets.UTF_8);
+ var parts = new String(message.getChannel(), StandardCharsets.UTF_8).split("/");
+ var origin = parts[1];
+ var source = String.join("/", copyOfRange(parts, 2, parts.length));
+ responseRedisChannel().send(MessageBuilder.createMessage(response, responseHeaders(origin, source)));
+ }, of("response/*"));
+ return container;
+ }
+
+ @Bean
+ public IntegrationFlow redisPublishUserFlow() {
+ return IntegrationFlow
+ .from(userTxChannel)
+ .handle(new CustomPublishingMessageHandler() {
+ @Override
+ protected String getTopic(Message message) {
+ return "user/" + formatOrigin(message.getHeaders().get("origin")) + "/" + message.getHeaders().get("tag");
+ }
+
+ @Override
+ protected byte[] getMessage(Message message) {
+ try {
+ return objectMapper.writeValueAsBytes(message.getPayload());
+ } catch (JsonProcessingException e) {
+ logger.error("Cannot serialize UserDto.");
+ throw new RuntimeException(e);
+ }
+ }
+ })
+ .get();
+ }
+
+ @Bean
+ public IntegrationFlow redisSubscribeUserFlow() {
+ return IntegrationFlow
+ .from(userRedisChannel())
+ .channel(userRxChannel)
+ .get();
+ }
+
+ @Bean
+ public RedisMessageListenerContainer redisUserRxAdapter(RedisConnectionFactory redisConnectionFactory) {
+ var container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(redisConnectionFactory);
+ container.addMessageListener((message, pattern) -> {
+ try {
+ var user = objectMapper.readValue(message.getBody(), UserDto.class);
+ var parts = new String(message.getChannel(), StandardCharsets.UTF_8).split("/");
+ var origin = parts[1];
+ var tag = String.join("/", copyOfRange(parts, 2, parts.length));
+ userRedisChannel().send(MessageBuilder.createMessage(user, tagHeaders(origin, tag)));
+ } catch (IOException e) {
+ logger.error("Error parsing UserDto from redis.");
+ }
+ }, of("user/*"));
+ return container;
+ }
+
+ @Bean
+ public IntegrationFlow redisPublishExtFlow() {
+ return IntegrationFlow
+ .from(extTxChannel)
+ .handle(new CustomPublishingMessageHandler() {
+ @Override
+ protected String getTopic(Message message) {
+ return "ext/" + formatOrigin(message.getHeaders().get("origin")) + "/" + formatTag(message.getHeaders().get("tag"));
+ }
+
+ @Override
+ protected byte[] getMessage(Message message) {
+ try {
+ return objectMapper.writeValueAsBytes(message.getPayload());
+ } catch (JsonProcessingException e) {
+ logger.error("Cannot serialize ExtDto.");
+ throw new RuntimeException(e);
+ }
+ }
+ })
+ .get();
+ }
+
+ @Bean
+ public IntegrationFlow redisSubscribeExtFlow() {
+ return IntegrationFlow
+ .from(extRedisChannel())
+ .channel(extRxChannel)
+ .get();
+ }
+
+ @Bean
+ public RedisMessageListenerContainer redisExtRxAdapter(RedisConnectionFactory redisConnectionFactory) {
+ var container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(redisConnectionFactory);
+ container.addMessageListener((message, pattern) -> {
+ try {
+ var ext = objectMapper.readValue(message.getBody(), ExtDto.class);
+ var parts = new String(message.getChannel(), StandardCharsets.UTF_8).split("/");
+ var origin = parts[1];
+ var tag = String.join("/", copyOfRange(parts, 2, parts.length));
+ extRedisChannel().send(MessageBuilder.createMessage(ext, tagHeaders(origin, tag)));
+ } catch (IOException e) {
+ logger.error("Error parsing ExtDto from redis.");
+ }
+ }, of("ext/*"));
+ return container;
+ }
+
+ @Bean
+ public IntegrationFlow redisPublishPluginFlow() {
+ return IntegrationFlow
+ .from(pluginTxChannel)
+ .handle(new CustomPublishingMessageHandler() {
+ @Override
+ protected String getTopic(Message message) {
+ return "plugin/" + formatOrigin(message.getHeaders().get("origin")) + "/" + formatTag(message.getHeaders().get("tag"));
+ }
+
+ @Override
+ protected byte[] getMessage(Message message) {
+ try {
+ return objectMapper.writeValueAsBytes(message.getPayload());
+ } catch (JsonProcessingException e) {
+ logger.error("Cannot serialize PluginDto.");
+ throw new RuntimeException(e);
+ }
+ }
+ })
+ .get();
+ }
+
+ @Bean
+ public IntegrationFlow redisSubscribePluginFlow() {
+ return IntegrationFlow
+ .from(pluginRedisChannel())
+ .channel(pluginRxChannel)
+ .get();
+ }
+
+ @Bean
+ public RedisMessageListenerContainer redisPluginRxAdapter(RedisConnectionFactory redisConnectionFactory) {
+ var container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(redisConnectionFactory);
+ container.addMessageListener((message, pattern) -> {
+ try {
+ var plugin = objectMapper.readValue(message.getBody(), PluginDto.class);
+ var parts = new String(message.getChannel(), StandardCharsets.UTF_8).split("/");
+ var origin = parts[1];
+ var tag = String.join("/", copyOfRange(parts, 2, parts.length));
+ pluginRedisChannel().send(MessageBuilder.createMessage(plugin, tagHeaders(origin, tag)));
+ } catch (IOException e) {
+ logger.error("Error parsing PluginDto from redis.");
+ }
+ }, of("plugin/*"));
+ return container;
+ }
+
+ @Bean
+ public IntegrationFlow redisPublishTemplateFlow() {
+ return IntegrationFlow
+ .from(templateTxChannel)
+ .handle(new CustomPublishingMessageHandler() {
+ @Override
+ protected String getTopic(Message message) {
+ return "template/" + formatOrigin(message.getHeaders().get("origin")) + "/" + formatTag(message.getHeaders().get("tag"));
+ }
+
+ @Override
+ protected byte[] getMessage(Message message) {
+ try {
+ return objectMapper.writeValueAsBytes(message.getPayload());
+ } catch (JsonProcessingException e) {
+ logger.error("Cannot serialize TemplateDto.");
+ throw new RuntimeException(e);
+ }
+ }
+ })
+ .get();
+ }
+
+ @Bean
+ public IntegrationFlow redisSubscribeTemplateFlow() {
+ return IntegrationFlow
+ .from(templateRedisChannel())
+ .channel(templateRxChannel)
+ .get();
+ }
+
+ @Bean
+ public RedisMessageListenerContainer redisTemplateRxAdapter(RedisConnectionFactory redisConnectionFactory) {
+ var container = new RedisMessageListenerContainer();
+ container.setConnectionFactory(redisConnectionFactory);
+ container.addMessageListener((message, pattern) -> {
+ try {
+ var template = objectMapper.readValue(message.getBody(), TemplateDto.class);
+ var parts = new String(message.getChannel(), StandardCharsets.UTF_8).split("/");
+ var origin = parts[1];
+ var tag = String.join("/", copyOfRange(parts, 2, parts.length));
+ templateRedisChannel().send(MessageBuilder.createMessage(template, tagHeaders(origin, tag)));
+ } catch (IOException e) {
+ logger.error("Error parsing TemplateDto from redis.");
+ }
+ }, of("template/*"));
+ return container;
+ }
+
+ private abstract class CustomPublishingMessageHandler extends AbstractMessageHandler {
+
+ private final RedisTemplate, ?> template;
+
+ public CustomPublishingMessageHandler() {
+ template = new RedisTemplate<>();
+ template.setConnectionFactory(redisConnectionFactory);
+ template.setEnableDefaultSerializer(false);
+ template.afterPropertiesSet();
+ }
+
+ @Override
+ public String getComponentType() {
+ return "redis:outbound-channel-adapter";
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ protected void handleMessageInternal(Message> message) {
+ template.convertAndSend(getTopic((Message) message), getMessage((Message