Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import org.sitenetsoft.quarkus.seo.runtime.SeoPaths;

/**
* Find the prefix that Quarkus REST puts in front of every JAX-RS {@code @Path}.
* Find the prefix that Quarkus REST or RESTEasy Classic puts in front of every JAX-RS {@code @Path}.
*
* <p>
* The class reads the annotation by name, so the deployment module needs no REST dependency.
Expand All @@ -27,16 +27,26 @@ final class RestPaths {
private RestPaths() {
}

/**
* Return the configuration key that holds the REST path of the REST stack in use.
*
* @param resteasyClassic true when RESTEasy Classic is the REST stack
* @return {@code quarkus.resteasy.path} for RESTEasy Classic, {@code quarkus.rest.path} otherwise
*/
static String restPathKey(boolean resteasyClassic) {
return resteasyClassic ? "quarkus.resteasy.path" : "quarkus.rest.path";
}

/**
* Return the JAX-RS prefix of the application.
*
* <p>
* An {@code @ApplicationPath} on an {@code Application} subclass wins, as in Quarkus REST. Without one, the
* value of {@code quarkus.rest.path} applies. When several classes carry the annotation, the class whose name
* sorts first wins.
* value of the REST path key applies. See {@link #restPathKey(boolean)}. When several classes carry the annotation,
* the class whose name sorts first wins.
*
* @param index the combined index of the application
* @param restPath the value of {@code quarkus.rest.path}, empty when the key is not set
* @param restPath the value of the REST path key, empty when the key is not set
* @return the prefix with one leading slash and no trailing slash, or an empty string for the root
*/
static String prefix(IndexView index, Optional<String> restPath) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,7 @@ class SeoProcessor {

static final String FEATURE = "seo";
private static final String CACHE_MANAGER_CLASS = "org.sitenetsoft.quarkus.seo.runtime.CacheManagerSeoOutputCache";
private static final String ROBOTS_PATH = "/robots.txt";
static final String SECURITY_TXT_PATH = "/.well-known/security.txt";
static final String LLMS_TXT_PATH = "/llms.txt";
private static final Pattern INDEXNOW_KEY = Pattern.compile("[a-zA-Z0-9-]{8,128}");
private static final String REST_PATH = "quarkus.rest.path";
private static final String PAGE_TOKEN = "{n}";
private static final String PAGE_GROUP = "([1-9][0-9]*)";

Expand All @@ -86,19 +82,20 @@ FeatureBuildItem feature() {
* An output is off when the master switch or its feature switch is off. An output is also off when the
* application serves its path itself, as a static file, a JAX-RS resource or a Reactive Route. The step checks the
* sitemap, robots.txt, security.txt, the manifest, the IndexNow key file and llms.txt. A JAX-RS path includes the
* {@code @ApplicationPath} or {@code quarkus.rest.path} prefix. The step treats every match on a host-root path
* {@code @ApplicationPath} or REST path prefix. The step treats every match on a host-root path
* as owned, whatever the root path. The host-root paths are {@code /robots.txt}, {@code /.well-known/security.txt},
* the IndexNow key file and {@code /llms.txt}. The step logs nothing about owned paths.
*
* @param config the build-time configuration
* @param index the combined index
* @param capabilities the capabilities of the application
* @param statics the static resources, absent when the application has none
* @return the outputs
* @throws IllegalStateException when a sitemap or manifest path holds a colon, an asterisk or whitespace, or when
* IndexNow is on and its key is missing or invalid
*/
@BuildStep
SeoOutputsBuildItem outputs(SeoBuildConfig config, CombinedIndexBuildItem index,
SeoOutputsBuildItem outputs(SeoBuildConfig config, CombinedIndexBuildItem index, Capabilities capabilities,
Optional<StaticResourcesBuildItem> statics) {
if (!config.enabled()) {
return new SeoOutputsBuildItem(false, false, false, false, false, false, false, null, Set.of());
Expand All @@ -113,15 +110,15 @@ SeoOutputsBuildItem outputs(SeoBuildConfig config, CombinedIndexBuildItem index,
String indexNowKeyPath = null;
if (config.indexNow().enabled()) {
checkIndexNowKey(config.indexNow().key().orElse(null));
indexNowKeyPath = "/" + config.indexNow().key().get() + ".txt";
indexNowKeyPath = SeoPaths.indexNowKey(config.indexNow().key().get());
}
String sitemapPath = SeoPaths.normalize(config.sitemap().path());
String manifestPath = SeoPaths.normalize(config.manifest().path());
Set<String> appPaths = OwnedPaths.fromIndex(index.getIndex(), jaxrsPrefix(index));
Set<String> appPaths = OwnedPaths.fromIndex(index.getIndex(), jaxrsPrefix(index, capabilities));
Set<String> owned = new LinkedHashSet<>();
List<String> candidates = new ArrayList<>(List.of(sitemapPath, ROBOTS_PATH));
List<String> candidates = new ArrayList<>(List.of(sitemapPath, SeoPaths.ROBOTS_TXT));
if (config.securityTxt().enabled()) {
candidates.add(SECURITY_TXT_PATH);
candidates.add(SeoPaths.SECURITY_TXT);
}
if (config.manifest().enabled()) {
candidates.add(manifestPath);
Expand All @@ -130,19 +127,19 @@ SeoOutputsBuildItem outputs(SeoBuildConfig config, CombinedIndexBuildItem index,
candidates.add(indexNowKeyPath);
}
if (config.llmsTxt().enabled()) {
candidates.add(LLMS_TXT_PATH);
candidates.add(SeoPaths.LLMS_TXT);
}
for (String path : candidates) {
if (appPaths.contains(path) || OwnedPaths.ownedStatic(statics, path)) {
owned.add(path);
}
}
boolean sitemap = config.sitemap().enabled() && !owned.contains(sitemapPath);
boolean robots = config.robots().enabled() && !owned.contains(ROBOTS_PATH);
boolean securityTxt = config.securityTxt().enabled() && !owned.contains(SECURITY_TXT_PATH);
boolean robots = config.robots().enabled() && !owned.contains(SeoPaths.ROBOTS_TXT);
boolean securityTxt = config.securityTxt().enabled() && !owned.contains(SeoPaths.SECURITY_TXT);
boolean manifest = config.manifest().enabled() && !owned.contains(manifestPath);
boolean indexNow = config.indexNow().enabled() && !owned.contains(indexNowKeyPath);
boolean llmsTxt = config.llmsTxt().enabled() && !owned.contains(LLMS_TXT_PATH);
boolean llmsTxt = config.llmsTxt().enabled() && !owned.contains(SeoPaths.LLMS_TXT);
return new SeoOutputsBuildItem(config.meta().enabled(), sitemap, robots, securityTxt, manifest, indexNow,
llmsTxt, indexNowKeyPath, owned);
}
Expand Down Expand Up @@ -285,15 +282,17 @@ AdditionalBeanBuildItem sitemapBeans(SeoOutputsBuildItem outputs) {
*
* @param outputs the outputs that the extension registers
* @param index the combined index
* @param capabilities the capabilities of the application
* @param entries the producer of the entries
*/
@BuildStep
void sitemapEntries(SeoOutputsBuildItem outputs, CombinedIndexBuildItem index,
void sitemapEntries(SeoOutputsBuildItem outputs, CombinedIndexBuildItem index, Capabilities capabilities,
BuildProducer<SitemapEntryBuildItem> entries) {
if (!outputs.sitemap() && !outputs.llmsTxt()) {
return;
}
for (SitemapEntryBuildItem entry : SitemapEntryScanner.scan(index.getIndex(), jaxrsPrefix(index))) {
String prefix = jaxrsPrefix(index, capabilities);
for (SitemapEntryBuildItem entry : SitemapEntryScanner.scan(index.getIndex(), prefix)) {
entries.produce(entry);
}
}
Expand Down Expand Up @@ -447,7 +446,8 @@ void robotsRoute(SeoRecorder recorder, SeoBuildConfig config, SeoOutputsBuildIte
if (!outputs.robots()) {
return;
}
routes.produce(hostRootRoute(ROBOTS_PATH, recorder.robotsHandler(), config.robots().serveUnderRootPath(),
routes.produce(hostRootRoute(SeoPaths.ROBOTS_TXT, recorder.robotsHandler(),
config.robots().serveUnderRootPath(),
recorder, nonApplicationRootPath));
}

Expand All @@ -471,7 +471,7 @@ void securityTxtRoute(SeoRecorder recorder, SeoBuildConfig config, SeoOutputsBui
return;
}
boolean underRootPath = config.securityTxt().serveUnderRootPath();
routes.produce(hostRootRoute(SECURITY_TXT_PATH, recorder.securityTxtHandler(underRootPath), underRootPath,
routes.produce(hostRootRoute(SeoPaths.SECURITY_TXT, recorder.securityTxtHandler(underRootPath), underRootPath,
recorder, nonApplicationRootPath));
}

Expand All @@ -494,7 +494,8 @@ void llmsTxtRoute(SeoRecorder recorder, SeoBuildConfig config, SeoOutputsBuildIt
if (!outputs.llmsTxt()) {
return;
}
routes.produce(hostRootRoute(LLMS_TXT_PATH, recorder.llmsTxtHandler(), config.llmsTxt().serveUnderRootPath(),
routes.produce(hostRootRoute(SeoPaths.LLMS_TXT, recorder.llmsTxtHandler(),
config.llmsTxt().serveUnderRootPath(),
recorder, nonApplicationRootPath));
}

Expand Down Expand Up @@ -652,10 +653,12 @@ static void checkPath(String key, String value) {
* Return the JAX-RS prefix of the application.
*
* @param index the combined index
* @param capabilities the capabilities of the application
* @return the prefix, or an empty string for the root
*/
private static String jaxrsPrefix(CombinedIndexBuildItem index) {
Optional<String> restPath = ConfigProvider.getConfig().getOptionalValue(REST_PATH, String.class);
private static String jaxrsPrefix(CombinedIndexBuildItem index, Capabilities capabilities) {
String key = RestPaths.restPathKey(capabilities.isPresent(Capability.RESTEASY));
Optional<String> restPath = ConfigProvider.getConfig().getOptionalValue(key, String.class);
return RestPaths.prefix(index.getIndex(), restPath);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@
*/
@BuildSteps(onlyIf = IsLocalDevelopment.class)
public class SeoDevUiProcessor {

private static final String ROBOTS_PATH = "/robots.txt";
private static final String SECURITY_TXT_PATH = "/.well-known/security.txt";
private static final String LLMS_TXT_PATH = "/llms.txt";
private static final String EXTENSION = "extension";
private static final String APPLICATION = "application";
private static final String OFF = "off";
Expand Down Expand Up @@ -162,21 +158,23 @@ private static List<Map<String, String>> outputRows(SeoBuildConfig config, SeoOu
.getOptionalValue("quarkus.http.root-path", String.class).orElse("/"));
String sitemapPath = SeoPaths.normalize(config.sitemap().path());
String manifestPath = SeoPaths.normalize(config.manifest().path());
String indexNowPath = config.indexNow().key().isPresent() ? "/" + config.indexNow().key().get() + ".txt" : "";
String indexNowPath = config.indexNow().key().isPresent()
? SeoPaths.indexNowKey(config.indexNow().key().get())
: "";
List<Map<String, String>> rows = new ArrayList<>();
rows.add(row("meta", "", outputs.meta() ? EXTENSION : OFF));
rows.add(row("sitemap", rootPath + sitemapPath, servedBy(outputs.sitemap(), outputs, sitemapPath)));
rows.add(row("robots.txt", hostRoot(rootPath, config.robots().serveUnderRootPath()) + ROBOTS_PATH,
servedBy(outputs.robots(), outputs, ROBOTS_PATH)));
rows.add(row("robots.txt", hostRoot(rootPath, config.robots().serveUnderRootPath()) + SeoPaths.ROBOTS_TXT,
servedBy(outputs.robots(), outputs, SeoPaths.ROBOTS_TXT)));
rows.add(row("security.txt",
hostRoot(rootPath, config.securityTxt().serveUnderRootPath()) + SECURITY_TXT_PATH,
servedBy(outputs.securityTxt(), outputs, SECURITY_TXT_PATH)));
hostRoot(rootPath, config.securityTxt().serveUnderRootPath()) + SeoPaths.SECURITY_TXT,
servedBy(outputs.securityTxt(), outputs, SeoPaths.SECURITY_TXT)));
rows.add(row("manifest", rootPath + manifestPath, servedBy(outputs.manifest(), outputs, manifestPath)));
rows.add(row("IndexNow key",
indexNowPath.isEmpty() ? "" : hostRoot(rootPath, config.indexNow().serveUnderRootPath()) + indexNowPath,
indexNowPath.isEmpty() ? OFF : servedBy(outputs.indexNow(), outputs, indexNowPath)));
rows.add(row("llms.txt", hostRoot(rootPath, config.llmsTxt().serveUnderRootPath()) + LLMS_TXT_PATH,
servedBy(outputs.llmsTxt(), outputs, LLMS_TXT_PATH)));
rows.add(row("llms.txt", hostRoot(rootPath, config.llmsTxt().serveUnderRootPath()) + SeoPaths.LLMS_TXT,
servedBy(outputs.llmsTxt(), outputs, SeoPaths.LLMS_TXT)));
return rows;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.sitenetsoft.quarkus.seo.deployment;

import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;

class IndexNowKeyUnderRootPathTest {

@RegisterExtension
static final QuarkusUnitTest TEST = new QuarkusUnitTest()
.overrideConfigKey("quarkus.seo.indexable", "true")
.overrideConfigKey("quarkus.http.root-path", "/app")
.overrideConfigKey("quarkus.seo.indexnow.enabled", "true")
.overrideConfigKey("quarkus.seo.indexnow.key", "abcdef0123456789")
.overrideConfigKey("quarkus.seo.indexnow.serve-under-root-path", "true");

@Test
void keyFileMovesUnderTheRootPath() {
String body = given().basePath("").get("/app/abcdef0123456789.txt").then().statusCode(200).extract()
.asString();
assertThat(body).isEqualTo("abcdef0123456789");
given().basePath("").get("/abcdef0123456789.txt").then().statusCode(404);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.sitenetsoft.quarkus.seo.deployment;

import static io.restassured.RestAssured.given;
import static org.assertj.core.api.Assertions.assertThat;

import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import io.restassured.RestAssured;
import org.sitenetsoft.quarkus.seo.SitemapEntry;

class LeftoverResteasyPathTest {

@RegisterExtension
static final QuarkusUnitTest TEST = new QuarkusUnitTest()
.overrideConfigKey("quarkus.resteasy.path", "/legacy")
.overrideConfigKey("quarkus.seo.indexable", "true")
.withApplicationRoot(jar -> jar.addClasses(Products.class));

@Test
void leftoverResteasyPathDoesNotPrefixSitemapEntries() {
String base = "http://localhost:" + RestAssured.port;
String body = given().basePath("").get("/sitemap.xml").then().statusCode(200).extract().asString();
assertThat(body).contains("<loc>" + base + "/products</loc>");
assertThat(body).doesNotContain("/legacy");
}

@Path("/products")
public static class Products {

@GET
@Produces("text/plain")
@SitemapEntry
public String list() {
return "products";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package org.sitenetsoft.quarkus.seo.deployment;

import static io.restassured.RestAssured.given;

import java.util.stream.Stream;

import jakarta.enterprise.context.ApplicationScoped;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkus.test.QuarkusUnitTest;
import org.sitenetsoft.quarkus.seo.SitemapContributor;
import org.sitenetsoft.quarkus.seo.SitemapUrl;

class LlmsTxtUnderRootPathTest {

@RegisterExtension
static final QuarkusUnitTest TEST = new QuarkusUnitTest()
.overrideConfigKey("quarkus.http.root-path", "/app")
.overrideConfigKey("quarkus.seo.indexable", "true")
.overrideConfigKey("quarkus.seo.llms-txt.enabled", "true")
.overrideConfigKey("quarkus.seo.llms-txt.serve-under-root-path", "true")
.withApplicationRoot(jar -> jar.addClasses(Pages.class));

@Test
void llmsTxtMovesUnderTheRootPath() {
given().basePath("").get("/app/llms.txt").then().statusCode(200);
given().basePath("").get("/llms.txt").then().statusCode(404);
}

@ApplicationScoped
public static class Pages implements SitemapContributor {

@Override
public Stream<SitemapUrl> urls() {
return Stream.of(SitemapUrl.of("/a").title("A page"));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package org.sitenetsoft.quarkus.seo.deployment;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.io.IOException;
import java.util.Optional;

import jakarta.ws.rs.ApplicationPath;
import jakarta.ws.rs.core.Application;

import org.jboss.jandex.Index;
import org.jboss.jandex.Indexer;
import org.junit.jupiter.api.Test;

class RestPathsTest {

@ApplicationPath("/v1")
static class V1 extends Application {
}

@ApplicationPath("/b")
static class B extends Application {
}

@ApplicationPath("/a")
static class A extends Application {
}

private static Index index(Class<?>... classes) throws IOException {
Indexer indexer = new Indexer();
for (Class<?> type : classes) {
indexer.indexClass(type);
}
return indexer.complete();
}

@Test
void classicReadsTheResteasyKey() {
assertEquals("quarkus.resteasy.path", RestPaths.restPathKey(true));
}

@Test
void restReadsTheRestKey() {
assertEquals("quarkus.rest.path", RestPaths.restPathKey(false));
}

@Test
void applicationPathWins() throws IOException {
assertEquals("/v1", RestPaths.prefix(index(V1.class), Optional.of("/api")));
}

@Test
void keyApplies() throws IOException {
assertEquals("/api", RestPaths.prefix(index(), Optional.of("/api/")));
}

@Test
void firstSortedClassWins() throws IOException {
assertEquals("/a", RestPaths.prefix(index(B.class, A.class), Optional.empty()));
}
}
Loading
Loading