RECIPIENT =
+ AttributeKey.valueOf(RecipientTokenDecorator.class, "recipient");
+
+ private final RecipientService recipients;
+
+ public RecipientTokenDecorator(RecipientService recipients) {
+ this.recipients = recipients;
+ }
+
+ /** Serves the request as the recipient its bearer token belongs to. */
+ @Override
+ public HttpResponse serve(HttpService delegate, ServiceRequestContext ctx, HttpRequest req) {
+ OAuth2Token token = AuthTokenExtractors.oAuth2().apply(req.headers());
+ if (token == null || token.accessToken().isBlank()) {
+ throw ApiException.unauthenticated("bearer token is required");
+ }
+ // The token lookup queries the database, which must not block the event loop.
+ return HttpResponse.of(
+ () -> {
+ try {
+ ctx.setAttr(RECIPIENT, recipients.authenticate(token.accessToken()));
+ return delegate.serve(ctx, req);
+ } catch (Exception e) {
+ return HttpResponse.ofFailure(e);
+ }
+ },
+ ctx.blockingTaskExecutor());
+ }
+
+ /**
+ * Returns the recipient this decorator authenticated for the request. Only services mounted
+ * behind this decorator may call it; anywhere else there is no recipient, so it fails with a 401
+ * rather than letting the service run anonymously.
+ */
+ public static RecipientPrincipal recipient(ServiceRequestContext ctx) {
+ RecipientPrincipal recipient = ctx.attr(RECIPIENT);
+ if (recipient == null) {
+ throw ApiException.unauthenticated("this endpoint requires an authenticated recipient");
+ }
+ return recipient;
+ }
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientPrincipal.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientPrincipal.java
new file mode 100644
index 00000000..c1e207aa
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientPrincipal.java
@@ -0,0 +1,4 @@
+package io.opensharing.recipient;
+
+/** The recipient a sharing protocol request's bearer token belongs to. */
+public record RecipientPrincipal(String recipientId) {}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java
index 55862364..d5d89883 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java
@@ -24,7 +24,8 @@
* recipient needs CREATE_RECIPIENT from the catalog, and only the owner may change it. The owner
* and callers with USE_RECIPIENT from the catalog may read a recipient; listing without
* USE_RECIPIENT returns only the caller's own recipients. {@link #activate} is public: the
- * activation code itself is the credential.
+ * activation code itself is the credential. {@link #authenticate} checks the bearer tokens
+ * recipients then present.
*
* {@code activationBaseUrl} is the absolute URL recipient activation links are built under, such
* as {@code https://host/api/1.0/opensharing/activations}; the host knows its own address.
@@ -142,6 +143,17 @@ public ProfileFile activate(String code, String endpoint) {
SHARE_CREDENTIALS_VERSION, bearer, endpoint, EXPIRATION_TIME.format(token.getExpiresAt()));
}
+ /**
+ * Resolves a sharing protocol bearer token to its recipient. Fails with unauthenticated unless
+ * the token is activated and unexpired.
+ */
+ public RecipientPrincipal authenticate(String bearer) {
+ return recipients
+ .findUsableToken(TokenHashes.sha256(bearer), Instant.now())
+ .map(token -> new RecipientPrincipal(token.getRecipient().getId()))
+ .orElseThrow(() -> ApiException.unauthenticated("bearer token is invalid or expired"));
+ }
+
private static Instant expiresAt(Long days, Instant now) {
if (days == null) {
throw ApiException.invalidParameter("tokenExpirationDays is required");
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java
index 9af115db..3e67bcdf 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java
@@ -163,6 +163,20 @@ public RecipientTokenEntity activate(String activationCode, String tokenHash, In
});
}
+ /** The token with {@code tokenHash}, when it is activated and unexpired at {@code now}. */
+ public Optional findUsableToken(String tokenHash, Instant now) {
+ return tx.inTransaction(
+ true,
+ em ->
+ em.createQuery(
+ "select t from RecipientTokenEntity t where t.tokenHash = :hash",
+ RecipientTokenEntity.class)
+ .setParameter("hash", tokenHash)
+ .getResultStream()
+ .findFirst()
+ .filter(token -> token.isActivated() && token.getExpiresAt().isAfter(now)));
+ }
+
/**
* Supersedes live credentials of the recipient and persists a pending replacement. An
* unactivated credential or a zero grace window expires immediately because no recipient should
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java
index 34fc31e8..d576f1d5 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/runtime/OpenSharing.java
@@ -3,6 +3,7 @@
import io.opensharing.Transactions;
import io.opensharing.asset.SharedDataObjectEntity;
import io.opensharing.asset.SharedDataObjectStore;
+import io.opensharing.asset.share.ProtocolShares;
import io.opensharing.catalog.CatalogConnector;
import io.opensharing.recipient.RecipientEntity;
import io.opensharing.recipient.RecipientService;
@@ -25,19 +26,23 @@ public final class OpenSharing {
private final CatalogConnector catalog;
private final ShareService shares;
private final RecipientService recipients;
+ private final ProtocolShares protocolShares;
private OpenSharing(CatalogConnector catalog, Transactions tx) {
this.catalog = catalog;
+ ShareStore shareStore = new ShareStore(tx);
+ SharePermissionStore permissionStore = new SharePermissionStore(tx);
RecipientStore recipientStore = new RecipientStore(tx);
this.shares =
new ShareService(
catalog,
tx,
- new ShareStore(tx),
+ shareStore,
new SharedDataObjectStore(tx),
- new SharePermissionStore(tx),
+ permissionStore,
recipientStore);
this.recipients = new RecipientService(catalog, recipientStore);
+ this.protocolShares = new ProtocolShares(shareStore, permissionStore);
}
public static Builder builder() {
@@ -66,6 +71,11 @@ public RecipientService recipients() {
return recipients;
}
+ /** The shares recipients see through the sharing protocol. */
+ public ProtocolShares protocolShares() {
+ return protocolShares;
+ }
+
public static final class Builder {
private CatalogConnector catalog;
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java b/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java
index 55c4fb19..71726d58 100644
--- a/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/share/SharePermissionStore.java
@@ -70,6 +70,43 @@ public List list(ShareEntity share, int offset, int limit
.getResultList());
}
+ /**
+ * Shares the recipient holds SELECT on, ordered by name. Share names are unique, so the order
+ * is total.
+ */
+ public List listSharesFor(String recipientId, int offset, int limit) {
+ return tx.inTransaction(
+ true,
+ em ->
+ em.createQuery(
+ "select s from SharePermissionEntity p join p.share s"
+ + " where p.recipient.id = :recipientId and p.privilege = :privilege"
+ + " order by s.name",
+ ShareEntity.class)
+ .setParameter("recipientId", recipientId)
+ .setParameter("privilege", SharePrivilege.SELECT)
+ .setFirstResult(offset)
+ .setMaxResults(limit)
+ .getResultList());
+ }
+
+ /** Whether the recipient holds SELECT on {@code share}. */
+ public boolean hasSelect(ShareEntity share, String recipientId) {
+ return tx.inTransaction(
+ true,
+ em ->
+ !em.createQuery(
+ "select p.id from SharePermissionEntity p where p.share.id = :shareId"
+ + " and p.recipient.id = :recipientId and p.privilege = :privilege",
+ String.class)
+ .setParameter("shareId", share.getId())
+ .setParameter("recipientId", recipientId)
+ .setParameter("privilege", SharePrivilege.SELECT)
+ .setMaxResults(1)
+ .getResultList()
+ .isEmpty());
+ }
+
private static Optional find(
EntityManager em, ShareEntity share, RecipientEntity recipient, SharePrivilege privilege) {
return em.createQuery(
diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/http/ProtocolPaginationTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/http/ProtocolPaginationTest.java
new file mode 100644
index 00000000..ea6ba2d1
--- /dev/null
+++ b/server/opensharing-server-core/src/test/java/io/opensharing/http/ProtocolPaginationTest.java
@@ -0,0 +1,46 @@
+package io.opensharing.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.IntStream;
+import org.junit.jupiter.api.Test;
+
+class ProtocolPaginationTest {
+
+ private static final List ROWS = IntStream.range(0, 10).boxed().toList();
+
+ @Test
+ void returnsNoItemsButATokenForAZeroPageSize() {
+ ListResponse empty = page(ROWS, 0, null);
+ assertEquals(List.of(), empty.items());
+ assertEquals(List.of(0, 1), page(ROWS, 2, empty.nextPageToken()).items());
+
+ ListResponse later = page(ROWS, 0, page(ROWS, 4, null).nextPageToken());
+ assertEquals(List.of(4, 5), page(ROWS, 2, later.nextPageToken()).items());
+ }
+
+ @Test
+ void returnsNoTokenForAZeroPageSizeOfAnEmptyList() {
+ ListResponse empty = page(List.of(), 0, null);
+ assertEquals(List.of(), empty.items());
+ assertNull(empty.nextPageToken());
+ }
+
+ @Test
+ void pagesOtherSizesLikeProviderLists() {
+ assertEquals(ROWS, page(ROWS, null, null).items());
+ assertEquals(List.of(0, 1, 2), page(ROWS, 3, null).items());
+ }
+
+ private static ListResponse page(List rows, Integer maxResults, String token) {
+ return Pagination.protocolPage(
+ maxResults,
+ token,
+ (offset, limit) ->
+ rows.subList(Math.min(offset, rows.size()), Math.min(offset + limit, rows.size())),
+ Function.identity());
+ }
+}
diff --git a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
index a94023b2..e09cbd28 100644
--- a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
+++ b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
@@ -16,7 +16,7 @@
import io.opensharing.http.ApiExceptionHandler;
import io.opensharing.http.ApiJson;
import io.opensharing.persistence.JpaTransactions;
-import io.opensharing.protocol.ActivationService;
+import io.opensharing.protocol.OpenSharingRecipientApi;
import io.opensharing.recipient.RecipientAdminService;
import io.opensharing.runtime.OpenSharing;
import io.opensharing.share.ShareAdminService;
@@ -56,10 +56,11 @@ public static void main(String[] args) throws InterruptedException {
/**
* Starts a server configured by {@code properties} and returns once it is listening. Provider
- * services are served under {@code provider.base-path}, recipient activation under {@code
- * activation.base-path} without a provider token, and any other path gets a 404 in the
- * protocol's error format. Links handed to recipients are built on {@code server.public-url},
- * which must be set. If startup fails, the database connections are closed.
+ * services are served under {@code provider.base-path}, the sharing protocol under {@code
+ * protocol.base-path} behind recipient tokens, recipient activation under {@code
+ * activation.base-path} behind none, and any other path gets a 404 in the protocol's error
+ * format. Links handed to recipients are built on {@code server.public-url}, which must be set.
+ * If startup fails, the database connections are closed.
*/
public static OpenSharingServer start(ServerProperties properties) {
String publicUrl = properties.getPublicUrl();
@@ -100,14 +101,12 @@ public static OpenSharingServer start(ServerProperties properties) {
new RecipientAdminService(
openSharing.recipients(),
publicUrl + properties.get(Property.ACTIVATION_BASE_PATH)));
- builder
- .annotatedService()
- .pathPrefix(properties.get(Property.ACTIVATION_BASE_PATH))
- .decorator(errors)
- .build(
- new ActivationService(
- openSharing.recipients(),
- publicUrl + properties.get(Property.PROTOCOL_BASE_PATH)));
+ OpenSharingRecipientApi.mount(
+ builder,
+ publicUrl,
+ properties.get(Property.PROTOCOL_BASE_PATH),
+ properties.get(Property.ACTIVATION_BASE_PATH),
+ openSharing);
Server server = builder.build();
try {
server.start().join();
diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java
new file mode 100644
index 00000000..521498a5
--- /dev/null
+++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/OpenSharingProtocolServiceTest.java
@@ -0,0 +1,156 @@
+package io.opensharing.protocol;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.linecorp.armeria.common.MediaType;
+import io.opensharing.TestServer.Response;
+import io.opensharing.http.ErrorCodes;
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.Test;
+
+class OpenSharingProtocolServiceTest extends ProtocolApiSupport {
+
+ private static final String SHARES = PROTOCOL + "/shares";
+
+ @Test
+ void authenticatesARecipientAndListsOnlyGrantedShares() {
+ // Create two shares and grant only one to the recipient.
+ createShare("protocol-granted", "Visible");
+ createShare("protocol-hidden", "Hidden");
+ String bearer = createAndActivateRecipient("protocol-partner");
+ grant("protocol-granted", "protocol-partner");
+
+ // The protocol list exposes the granted share and its protocol metadata only.
+ Response list = server.get(SHARES, bearer);
+ assertEquals(200, list.status());
+ assertJsonUtf8(list);
+ assertEquals(1, list.body().path("items").size());
+ JsonNode listed = list.body().path("items").path(0);
+ assertEquals("protocol-granted", listed.path("name").asText());
+ assertEquals("Visible", listed.path("displayName").asText());
+ assertTrue(listed.has("id"));
+ assertTrue(listed.path("objects").isMissingNode());
+ assertTrue(list.body().path("nextPageToken").isMissingNode());
+
+ // Get wraps the granted share; lookup is case-insensitive.
+ Response get = server.get(SHARES + "/PROTOCOL-GRANTED", bearer);
+ assertEquals(200, get.status());
+ assertJsonUtf8(get);
+ JsonNode share = get.body().path("share");
+ assertEquals("protocol-granted", share.path("name").asText());
+ assertEquals("Visible", share.path("displayName").asText());
+ assertTrue(share.has("id"));
+ assertTrue(share.path("objects").isMissingNode());
+
+ // An ungranted share is reported as missing rather than forbidden.
+ Response hidden = server.get(SHARES + "/protocol-hidden", bearer);
+ assertEquals(404, hidden.status());
+ assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, hidden.errorCode());
+ }
+
+ @Test
+ void rejectsMissingInvalidAndSupersededTokens() {
+ // Protocol requests require an activated recipient bearer.
+ Response missing = server.get(SHARES, null);
+ assertEquals(401, missing.status());
+ assertEquals("Bearer", missing.headers().get("WWW-Authenticate"));
+ assertEquals(ErrorCodes.UNAUTHENTICATED, missing.errorCode());
+ Response invalid = server.get(SHARES, "invalid");
+ assertEquals(401, invalid.status());
+ assertEquals(ErrorCodes.UNAUTHENTICATED, invalid.errorCode());
+
+ // Rotation with the default zero grace period invalidates the old bearer immediately.
+ String bearer = createAndActivateRecipient("protocol-rotated");
+ Response rotated =
+ server.post(
+ PROVIDER + "/recipients/protocol-rotated/rotate-token",
+ ALICE,
+ "{\"tokenExpirationDays\":1}");
+ assertEquals(201, rotated.status());
+ Response superseded = server.get(SHARES, bearer);
+ assertEquals(401, superseded.status());
+ assertEquals(ErrorCodes.UNAUTHENTICATED, superseded.errorCode());
+ }
+
+ @Test
+ void paginatesGrantedSharesWithOpaqueTokens() {
+ // Grant two ordered shares to one recipient.
+ createShare("protocol-page-a", "A");
+ createShare("protocol-page-b", "B");
+ String bearer = createAndActivateRecipient("protocol-paged");
+ grant("protocol-page-a", "protocol-paged");
+ grant("protocol-page-b", "protocol-paged");
+
+ // Resume from the opaque token without repeating the first item.
+ Response first = server.get(page(SHARES, 1, null), bearer);
+ assertEquals(200, first.status());
+ assertEquals(1, first.body().path("items").size());
+ String pageToken = first.body().path("nextPageToken").asText();
+ assertFalse(pageToken.isEmpty());
+ Response second = server.get(page(SHARES, 1, pageToken), bearer);
+ assertEquals(200, second.status());
+ assertEquals(1, second.body().path("items").size());
+ assertTrue(second.body().path("nextPageToken").isMissingNode());
+ assertNotEquals(
+ first.body().path("items").path(0).path("name").asText(),
+ second.body().path("items").path(0).path("name").asText());
+
+ // A page size of 0 returns no items, with a token that resumes from the same position.
+ Response empty = server.get(page(SHARES, 0, null), bearer);
+ assertEquals(200, empty.status());
+ assertEquals(0, empty.body().path("items").size());
+ String resume = empty.body().path("nextPageToken").asText();
+ assertEquals(2, server.get(page(SHARES, 2, resume), bearer).body().path("items").size());
+
+ // Malformed pagination input is rejected.
+ Response malformed = server.get(SHARES + "?pageToken=not-a-token", bearer);
+ assertEquals(400, malformed.status());
+ assertEquals(ErrorCodes.INVALID_PARAMETER_VALUE, malformed.errorCode());
+ }
+
+ @Test
+ void listsManyGrantedSharesAcrossPages() {
+ int total = 25;
+ String bearer = createAndActivateRecipient("protocol-many");
+ for (int i = 0; i < total; i++) {
+ String name = "protocol-many-%02d".formatted(i);
+ createShare(name, "Share " + i);
+ grant(name, "protocol-many");
+ }
+
+ Response first = server.get(page(SHARES, 10, null), bearer);
+ assertNames(first, 10, "protocol-many-00", "protocol-many-09");
+ Response second =
+ server.get(page(SHARES, 10, first.body().path("nextPageToken").asText()), bearer);
+ assertNames(second, 10, "protocol-many-10", "protocol-many-19");
+ Response last =
+ server.get(page(SHARES, 10, second.body().path("nextPageToken").asText()), bearer);
+ assertNames(last, 5, "protocol-many-20", "protocol-many-24");
+ assertTrue(last.body().path("nextPageToken").isMissingNode());
+
+ // A page size above the maximum is capped rather than rejected.
+ Response all = server.get(page(SHARES, 10000, null), bearer);
+ assertNames(all, total, "protocol-many-00", "protocol-many-24");
+ assertTrue(all.body().path("nextPageToken").isMissingNode());
+ }
+
+ /** The protocol serves its bodies as {@code application/json} in UTF-8. */
+ private static void assertJsonUtf8(Response response) {
+ MediaType contentType = response.headers().contentType();
+ assertTrue(contentType.is(MediaType.JSON), contentType.toString());
+ assertEquals(StandardCharsets.UTF_8, contentType.charset());
+ }
+
+ /** A page of {@code size} shares from {@code first} to {@code last}. */
+ private static void assertNames(Response response, int size, String first, String last) {
+ assertEquals(200, response.status());
+ JsonNode items = response.body().path("items");
+ assertEquals(size, items.size());
+ assertEquals(first, items.path(0).path("name").asText());
+ assertEquals(last, items.path(size - 1).path("name").asText());
+ }
+}
diff --git a/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java
new file mode 100644
index 00000000..048b8383
--- /dev/null
+++ b/server/opensharing-server/src/test/java/io/opensharing/protocol/ProtocolApiSupport.java
@@ -0,0 +1,75 @@
+package io.opensharing.protocol;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import io.opensharing.TestServer;
+import io.opensharing.TestServer.Response;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+
+/**
+ * Provider setup shared by recipient protocol API tests. Each test class gets its own server.
+ * Catalog lookup and provider auth go through {@code LocalCatalogConnector} and {@code
+ * local-catalog.yml}.
+ */
+public abstract class ProtocolApiSupport {
+
+ protected static final String PROVIDER = "/api/1.0/opensharing/provider";
+ protected static final String PROTOCOL = "/api/1.0/opensharing";
+ protected static final String ALICE = "alice-token";
+
+ protected static TestServer server;
+
+ @BeforeAll
+ static void startServer() {
+ server = TestServer.start();
+ }
+
+ @AfterAll
+ static void stopServer() {
+ server.close();
+ }
+
+ /** Creates a share owned by Alice through the provider API. */
+ protected static void createShare(String name, String displayName) {
+ String body = "{\"name\":\"%s\",\"displayName\":\"%s\"}".formatted(name, displayName);
+ assertEquals(201, server.post(PROVIDER + "/shares", ALICE, body).status());
+ }
+
+ /** Creates a TOKEN recipient, redeems its activation URL, and returns the bearer token. */
+ protected static String createAndActivateRecipient(String name) {
+ Response created =
+ server.post(
+ PROVIDER + "/recipients",
+ ALICE,
+ "{\"name\":\"%s\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":30}"
+ .formatted(name));
+ assertEquals(201, created.status());
+ String activationPath =
+ URI.create(created.body().path("activationUrl").asText()).getPath();
+ Response profile = server.get(activationPath, null);
+ assertEquals(200, profile.status());
+ return profile.body().path("bearerToken").asText();
+ }
+
+ /** Grants {@code recipient} SELECT on Alice's {@code share} through the provider API. */
+ protected static void grant(String share, String recipient) {
+ String body = "{\"changes\":[{\"recipientName\":\"%s\",\"add\":[\"SELECT\"]}]}";
+ Response granted =
+ server.patch(
+ PROVIDER + "/shares/" + share + "/permissions", ALICE, body.formatted(recipient));
+ assertEquals(200, granted.status());
+ }
+
+ /** {@code path} with a {@code maxResults} and, unless null, a {@code pageToken} parameter. */
+ protected static String page(String path, int maxResults, String pageToken) {
+ String query = "?maxResults=" + maxResults;
+ if (pageToken != null) {
+ query += "&pageToken=" + URLEncoder.encode(pageToken, StandardCharsets.UTF_8);
+ }
+ return path + query;
+ }
+}