diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/AuthenticationType.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/AuthenticationType.java
new file mode 100644
index 00000000..2a1fea98
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/AuthenticationType.java
@@ -0,0 +1,6 @@
+package io.opensharing.recipient;
+
+/** How a recipient authenticates to this server. */
+public enum AuthenticationType {
+ TOKEN
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/CreateRecipientRequest.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/CreateRecipientRequest.java
new file mode 100644
index 00000000..20583d70
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/CreateRecipientRequest.java
@@ -0,0 +1,8 @@
+package io.opensharing.recipient;
+
+/**
+ * POST body to create a recipient. The authenticated caller becomes the owner. Only {@code TOKEN}
+ * authentication is accepted for now.
+ */
+public record CreateRecipientRequest(
+ String name, String comment, AuthenticationType authenticationType) {}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientEntity.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientEntity.java
new file mode 100644
index 00000000..37be07af
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientEntity.java
@@ -0,0 +1,68 @@
+package io.opensharing.recipient;
+
+import io.opensharing.BaseEntity;
+import io.opensharing.ObjectNames;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+
+/**
+ * Persisted recipient row: someone a provider shares data with. {@code name} is always stored
+ * lowercase. Credentials live in {@link RecipientTokenEntity}.
+ */
+@Entity
+@Table(
+ name = "os_recipients",
+ uniqueConstraints = @UniqueConstraint(name = "uk_recipients_name", columnNames = "name"))
+public class RecipientEntity extends BaseEntity {
+
+ @Column(nullable = false, length = 255)
+ private String name;
+
+ @Column(length = 8192)
+ private String comment;
+
+ /** userId of whoever created this recipient. Not a foreign key: there is no principal table. */
+ @Column(name = "owner_id", nullable = false, length = 255)
+ private String ownerId;
+
+ /** How the recipient authenticates to the sharing protocol. */
+ @Enumerated(EnumType.STRING)
+ @Column(name = "authentication_type", nullable = false, length = 32)
+ private AuthenticationType authenticationType;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = ObjectNames.normalize(name);
+ }
+
+ public String getComment() {
+ return comment;
+ }
+
+ public void setComment(String comment) {
+ this.comment = comment;
+ }
+
+ public String getOwnerId() {
+ return ownerId;
+ }
+
+ public void setOwnerId(String ownerId) {
+ this.ownerId = ownerId;
+ }
+
+ public AuthenticationType getAuthenticationType() {
+ return authenticationType;
+ }
+
+ public void setAuthenticationType(AuthenticationType authenticationType) {
+ this.authenticationType = authenticationType;
+ }
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientResponse.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientResponse.java
new file mode 100644
index 00000000..e897fb87
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientResponse.java
@@ -0,0 +1,35 @@
+package io.opensharing.recipient;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+/**
+ * A recipient as the provider API reports it. {@code activationUrl} is the link the provider sends
+ * to the recipient; omitted when the recipient has no activation code.
+ *
+ *
{@code
+ * {
+ * "id": "",
+ * "name": "",
+ * "comment": "",
+ * "authenticationType": "TOKEN",
+ * "activationUrl": ""
+ * }
+ * }
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record RecipientResponse(
+ String id,
+ String name,
+ String comment,
+ AuthenticationType authenticationType,
+ String activationUrl) {
+
+ public static RecipientResponse from(RecipientEntity recipient, String activationUrl) {
+ return new RecipientResponse(
+ recipient.getId(),
+ recipient.getName(),
+ recipient.getComment(),
+ recipient.getAuthenticationType(),
+ activationUrl);
+ }
+}
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
new file mode 100644
index 00000000..e5a2775f
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java
@@ -0,0 +1,84 @@
+package io.opensharing.recipient;
+
+import io.opensharing.ObjectNames;
+import io.opensharing.auth.AuthContext;
+import io.opensharing.auth.Privilege;
+import io.opensharing.auth.UserContext;
+import io.opensharing.catalog.CatalogConnector;
+import io.opensharing.http.ApiException;
+import io.opensharing.http.ListResponse;
+import java.util.UUID;
+
+/**
+ * Provider recipient CRUD for a caller the host has authenticated. Creating a recipient needs
+ * CREATE_RECIPIENT from the catalog, and only the owner may change it. Only the owner and callers
+ * with USE_RECIPIENT, who may read anyone's recipients, are meant to list and get recipients.
+ *
+ * {@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.
+ */
+public class RecipientService {
+
+ private final CatalogConnector catalog;
+ private final RecipientStore recipients;
+
+ public RecipientService(CatalogConnector catalog, RecipientStore recipients) {
+ this.catalog = catalog;
+ this.recipients = recipients;
+ }
+
+ /**
+ * Creates a recipient owned by the caller, who needs CREATE_RECIPIENT from the catalog, with a
+ * new activation code.
+ */
+ public RecipientResponse create(
+ UserContext user, CreateRecipientRequest request, String activationBaseUrl) {
+ // The host has already authenticated the caller. A host that also checks CREATE_RECIPIENT at
+ // its endpoint can make this a no-op.
+ catalog.authorize(AuthContext.of(user), Privilege.CREATE_RECIPIENT);
+ if (request.authenticationType() == null) {
+ throw ApiException.invalidParameter("authenticationType is required");
+ }
+ RecipientEntity recipient =
+ recipients.create(
+ user,
+ ObjectNames.validateRecipientName(request.name()),
+ request.comment(),
+ request.authenticationType(),
+ UUID.randomUUID().toString());
+ return toResponse(recipient, activationBaseUrl);
+ }
+
+ /** Lists every recipient by name, unpaged. */
+ public ListResponse list(String activationBaseUrl) {
+ // TODO: page with maxResults and pageToken.
+ // TODO: introduce USE_RECIPIENT and list only the caller's recipients unless they hold it.
+ return ListResponse.of(
+ recipients.list().stream().map(r -> toResponse(r, activationBaseUrl)).toList());
+ }
+
+ /** Gets a recipient by name in any case. */
+ public RecipientResponse get(String recipient, String activationBaseUrl) {
+ // TODO: introduce USE_RECIPIENT and get only the caller's recipient unless they hold it.
+ return toResponse(recipients.getOrThrow(recipient), activationBaseUrl);
+ }
+
+ /** Updates the fields set in {@code request}. Owner only. */
+ public RecipientResponse update(
+ UserContext user,
+ String recipient,
+ UpdateRecipientRequest request,
+ String activationBaseUrl) {
+ return toResponse(recipients.update(user, recipient, request.comment()), activationBaseUrl);
+ }
+
+ /** Deletes the recipient and its tokens. Owner only. */
+ public void delete(UserContext user, String recipient) {
+ recipients.delete(recipient, user);
+ }
+
+ private RecipientResponse toResponse(RecipientEntity recipient, String activationBaseUrl) {
+ String code = recipients.findActivationCode(recipient);
+ return RecipientResponse.from(recipient, code == null ? null : activationBaseUrl + "/" + code);
+ }
+}
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
new file mode 100644
index 00000000..223b8897
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java
@@ -0,0 +1,137 @@
+package io.opensharing.recipient;
+
+import io.opensharing.ObjectNames;
+import io.opensharing.Transactions;
+import io.opensharing.auth.Ownership;
+import io.opensharing.auth.UserContext;
+import io.opensharing.http.ApiException;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.LockModeType;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Storage for recipients and their tokens. Names are stored lowercase and looked up
+ * case-insensitively. Each method runs in its own transaction, so returned entities are detached.
+ */
+public class RecipientStore {
+
+ private final Transactions tx;
+
+ public RecipientStore(Transactions tx) {
+ this.tx = tx;
+ }
+
+ /**
+ * Creates a recipient owned by {@code author} and its first token holding {@code
+ * activationCode}. {@code name} must already be validated and lowercase. Fails with
+ * already-exists when the name is taken.
+ */
+ public RecipientEntity create(
+ UserContext author,
+ String name,
+ String comment,
+ AuthenticationType authenticationType,
+ String activationCode) {
+ return tx.inTransaction(
+ false,
+ em -> {
+ if (find(em, name, LockModeType.NONE).isPresent()) {
+ throw ApiException.alreadyExists("recipient '" + name + "' already exists");
+ }
+ RecipientEntity recipient = new RecipientEntity();
+ recipient.setName(name);
+ recipient.setComment(comment);
+ recipient.setOwnerId(author.userId());
+ recipient.setAuthenticationType(authenticationType);
+ em.persist(recipient);
+ RecipientTokenEntity token = new RecipientTokenEntity();
+ token.setRecipient(recipient);
+ token.setActivationCode(activationCode);
+ em.persist(token);
+ return recipient;
+ });
+ }
+
+ /** Only non-null fields are applied. Only the owner may update the recipient. */
+ public RecipientEntity update(UserContext user, String name, String comment) {
+ return tx.inTransaction(
+ false,
+ em -> {
+ RecipientEntity recipient = getOwnedOrThrow(em, name, user);
+ if (comment != null) {
+ recipient.setComment(comment);
+ }
+ return recipient;
+ });
+ }
+
+ /** Looks up a recipient by name in any case. Fails with not-found when it does not exist. */
+ public RecipientEntity getOrThrow(String name) {
+ return tx.inTransaction(true, em -> getOrThrow(em, name, LockModeType.NONE));
+ }
+
+ /** The newest token's activation code, or null when there is none. */
+ public String findActivationCode(RecipientEntity recipient) {
+ return tx.inTransaction(
+ true,
+ em ->
+ em.createQuery(
+ "select t.activationCode from RecipientTokenEntity t"
+ + " where t.recipient.id = :recipientId order by t.createdAt desc",
+ String.class)
+ .setParameter("recipientId", recipient.getId())
+ .setMaxResults(1)
+ .getResultStream()
+ .findFirst()
+ .orElse(null));
+ }
+
+ /** Every recipient, regardless of owner, ordered by name. */
+ public List list() {
+ return tx.inTransaction(
+ true,
+ em ->
+ em.createQuery(
+ "select r from RecipientEntity r order by r.name", RecipientEntity.class)
+ .getResultList());
+ }
+
+ /** Deletes a recipient and its tokens. Only the owner may delete it. */
+ public void delete(String name, UserContext user) {
+ tx.inTransaction(
+ false,
+ em -> {
+ RecipientEntity recipient = getOwnedOrThrow(em, name, user);
+ em.createQuery("delete from RecipientTokenEntity t where t.recipient.id = :recipientId")
+ .setParameter("recipientId", recipient.getId())
+ .executeUpdate();
+ em.remove(recipient);
+ return null;
+ });
+ }
+
+ private static Optional find(EntityManager em, String name, LockModeType lock) {
+ return em.createQuery(
+ "select r from RecipientEntity r where r.name = :name", RecipientEntity.class)
+ .setParameter("name", ObjectNames.normalize(name))
+ .setLockMode(lock)
+ .getResultStream()
+ .findFirst();
+ }
+
+ private static RecipientEntity getOrThrow(EntityManager em, String name, LockModeType lock) {
+ return find(em, name, lock)
+ .orElseThrow(() -> ApiException.notFound("recipient '" + name + "' does not exist"));
+ }
+
+ /**
+ * Locks the row of a recipient the caller owns, so changes to one recipient run one at a time and
+ * each sees what the one before it wrote.
+ */
+ private static RecipientEntity getOwnedOrThrow(EntityManager em, String name, UserContext user) {
+ RecipientEntity recipient = getOrThrow(em, name, LockModeType.PESSIMISTIC_WRITE);
+ Ownership.check(user, recipient.getOwnerId(), "recipient '" + recipient.getName() + "'");
+ return recipient;
+ }
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientTokenEntity.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientTokenEntity.java
new file mode 100644
index 00000000..a7c8dce3
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientTokenEntity.java
@@ -0,0 +1,46 @@
+package io.opensharing.recipient;
+
+import io.opensharing.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+
+/** A token issued to a recipient. {@code activationCode} is stored plaintext until redeemed. */
+@Entity
+@Table(
+ name = "os_recipient_tokens",
+ uniqueConstraints =
+ @UniqueConstraint(
+ name = "uk_recipient_tokens_activation_code",
+ columnNames = "activation_code"))
+public class RecipientTokenEntity extends BaseEntity {
+
+ /** The recipient the token belongs to. {@code RecipientStore} deletes tokens with it. */
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "recipient_id", nullable = false)
+ private RecipientEntity recipient;
+
+ /** One-time code embedded in the activation URL. */
+ @Column(name = "activation_code", unique = true, length = 36)
+ private String activationCode;
+
+ public RecipientEntity getRecipient() {
+ return recipient;
+ }
+
+ public void setRecipient(RecipientEntity recipient) {
+ this.recipient = recipient;
+ }
+
+ public String getActivationCode() {
+ return activationCode;
+ }
+
+ public void setActivationCode(String activationCode) {
+ this.activationCode = activationCode;
+ }
+}
diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/recipient/UpdateRecipientRequest.java b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/UpdateRecipientRequest.java
new file mode 100644
index 00000000..52dcbc87
--- /dev/null
+++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/UpdateRecipientRequest.java
@@ -0,0 +1,4 @@
+package io.opensharing.recipient;
+
+/** PATCH body for recipient metadata. Null fields are left unchanged. */
+public record UpdateRecipientRequest(String comment) {}
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 dd495b8a..f3bd4d14 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
@@ -4,6 +4,10 @@
import io.opensharing.asset.SharedDataObjectEntity;
import io.opensharing.asset.SharedDataObjectStore;
import io.opensharing.catalog.CatalogConnector;
+import io.opensharing.recipient.RecipientEntity;
+import io.opensharing.recipient.RecipientService;
+import io.opensharing.recipient.RecipientStore;
+import io.opensharing.recipient.RecipientTokenEntity;
import io.opensharing.share.ShareEntity;
import io.opensharing.share.ShareService;
import io.opensharing.share.ShareStore;
@@ -18,11 +22,13 @@ public final class OpenSharing {
private final CatalogConnector catalog;
private final ShareService shares;
+ private final RecipientService recipients;
private OpenSharing(CatalogConnector catalog, Transactions tx) {
this.catalog = catalog;
this.shares =
new ShareService(catalog, tx, new ShareStore(tx), new SharedDataObjectStore(tx));
+ this.recipients = new RecipientService(catalog, new RecipientStore(tx));
}
public static Builder builder() {
@@ -31,7 +37,11 @@ public static Builder builder() {
/** The entity classes the host's {@link Transactions} must register. */
public static List> entities() {
- return List.of(ShareEntity.class, SharedDataObjectEntity.class);
+ return List.of(
+ ShareEntity.class,
+ SharedDataObjectEntity.class,
+ RecipientEntity.class,
+ RecipientTokenEntity.class);
}
public CatalogConnector catalog() {
@@ -42,6 +52,10 @@ public ShareService shares() {
return shares;
}
+ public RecipientService recipients() {
+ return recipients;
+ }
+
public static final class Builder {
private CatalogConnector catalog;
diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/recipient/RecipientServiceTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/recipient/RecipientServiceTest.java
new file mode 100644
index 00000000..fd6c44c7
--- /dev/null
+++ b/server/opensharing-server-core/src/test/java/io/opensharing/recipient/RecipientServiceTest.java
@@ -0,0 +1,52 @@
+package io.opensharing.recipient;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import io.opensharing.Transactions;
+import io.opensharing.auth.Privilege;
+import io.opensharing.auth.UserContext;
+import io.opensharing.catalog.CatalogConnector;
+import io.opensharing.exception.CatalogAuthorizationException;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Proxy;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class RecipientServiceTest {
+
+ @Test
+ void createsARecipientOnlyWithCreateRecipientFromTheCatalog() {
+ List privileges = new ArrayList<>();
+ CatalogConnector catalog =
+ proxy(
+ CatalogConnector.class,
+ (proxy, method, args) -> {
+ privileges.add((Privilege) args[1]);
+ throw new CatalogAuthorizationException("no CREATE_RECIPIENT");
+ });
+ Transactions unused =
+ proxy(
+ Transactions.class,
+ (proxy, method, args) -> {
+ throw new UnsupportedOperationException();
+ });
+ RecipientService recipients = new RecipientService(catalog, new RecipientStore(unused));
+ UserContext alice = UserContext.fromUserIdAndName("alice-id", "alice");
+
+ assertThrows(
+ CatalogAuthorizationException.class,
+ () ->
+ recipients.create(
+ alice,
+ new CreateRecipientRequest("acme", null, AuthenticationType.TOKEN),
+ "http://host/activations"));
+ assertEquals(List.of(Privilege.CREATE_RECIPIENT), privileges);
+ }
+
+ private static T proxy(Class type, InvocationHandler handler) {
+ return type.cast(
+ Proxy.newProxyInstance(type.getClassLoader(), new Class>[] {type}, handler));
+ }
+}
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 e4dd6c7d..3bdb4fe5 100644
--- a/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
+++ b/server/opensharing-server/src/main/java/io/opensharing/OpenSharingServer.java
@@ -16,6 +16,7 @@
import io.opensharing.http.ApiExceptionHandler;
import io.opensharing.http.ApiJson;
import io.opensharing.persistence.JpaTransactions;
+import io.opensharing.recipient.RecipientAdminService;
import io.opensharing.runtime.OpenSharing;
import io.opensharing.share.ShareAdminService;
import java.util.List;
@@ -55,9 +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}, and any other path gets a 404 in the
- * protocol's error format. If startup fails, the database connections are closed.
+ * 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();
CatalogConnector catalog = catalog(properties);
JpaTransactions transactions = new JpaTransactions(OpenSharing.entities());
try {
@@ -84,6 +87,14 @@ public static OpenSharingServer start(ServerProperties properties) {
providerToken,
errors,
new ShareAdminService(openSharing.shares()));
+ mountProvider(
+ builder,
+ providerPath,
+ providerToken,
+ errors,
+ new RecipientAdminService(
+ openSharing.recipients(),
+ publicUrl + properties.get(Property.ACTIVATION_BASE_PATH)));
Server server = builder.build();
try {
server.start().join();
diff --git a/server/opensharing-server/src/main/java/io/opensharing/config/ServerProperties.java b/server/opensharing-server/src/main/java/io/opensharing/config/ServerProperties.java
index 633f5719..3bf221c2 100644
--- a/server/opensharing-server/src/main/java/io/opensharing/config/ServerProperties.java
+++ b/server/opensharing-server/src/main/java/io/opensharing/config/ServerProperties.java
@@ -3,6 +3,8 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
+import java.net.URI;
+import java.net.URISyntaxException;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
@@ -22,7 +24,9 @@ public class ServerProperties {
public enum Property {
PORT("server.port", "8080"),
+ PUBLIC_URL("server.public-url", null),
PROVIDER_BASE_PATH("provider.base-path", "/api/1.0/opensharing/provider"),
+ ACTIVATION_BASE_PATH("activation.base-path", "/api/1.0/opensharing/activations"),
CATALOG_TYPE("catalog.type", "local"),
LOCAL_CATALOG_FILE("catalog.local.file", "classpath:local-catalog.yml");
@@ -79,10 +83,46 @@ public int getPort() {
}
}
+ /**
+ * The address recipients reach this server at, such as {@code https://sharing.example.com},
+ * without a trailing slash. Activation links and the endpoint in profile files are built on it,
+ * so it must be set: the address a request arrives on can be internal to a proxy.
+ */
+ public String getPublicUrl() {
+ String value = get(Property.PUBLIC_URL);
+ if (value == null) {
+ throw new IllegalArgumentException(
+ Property.PUBLIC_URL.key
+ + " is required; set it to the address recipients reach this server at");
+ }
+ String url = value.trim();
+ URI uri;
+ try {
+ uri = new URI(url);
+ } catch (URISyntaxException e) {
+ uri = null;
+ }
+ if (uri == null
+ || !("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme()))
+ || uri.getHost() == null
+ || uri.getRawQuery() != null
+ || uri.getRawFragment() != null) {
+ throw new IllegalArgumentException(
+ "invalid value '"
+ + value
+ + "' for "
+ + Property.PUBLIC_URL.key
+ + "; expected an http or https URL");
+ }
+ return url.replaceAll("/+$", "");
+ }
+
private static Properties defaults() {
Properties defaults = new Properties();
for (Property property : Property.values()) {
- defaults.setProperty(property.key, property.defaultValue);
+ if (property.defaultValue != null) {
+ defaults.setProperty(property.key, property.defaultValue);
+ }
}
return defaults;
}
diff --git a/server/opensharing-server/src/main/java/io/opensharing/recipient/RecipientAdminService.java b/server/opensharing-server/src/main/java/io/opensharing/recipient/RecipientAdminService.java
new file mode 100644
index 00000000..44145edc
--- /dev/null
+++ b/server/opensharing-server/src/main/java/io/opensharing/recipient/RecipientAdminService.java
@@ -0,0 +1,74 @@
+package io.opensharing.recipient;
+
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.server.ServiceRequestContext;
+import com.linecorp.armeria.server.annotation.Blocking;
+import com.linecorp.armeria.server.annotation.Delete;
+import com.linecorp.armeria.server.annotation.Get;
+import com.linecorp.armeria.server.annotation.Param;
+import com.linecorp.armeria.server.annotation.Patch;
+import com.linecorp.armeria.server.annotation.Post;
+import com.linecorp.armeria.server.annotation.ProducesJson;
+import com.linecorp.armeria.server.annotation.StatusCode;
+import io.opensharing.auth.ProviderTokenDecorator;
+import io.opensharing.http.ListResponse;
+
+/**
+ * Provider HTTP API for recipient CRUD, behind {@link ProviderTokenDecorator}. Only the owner may
+ * change a recipient. Only the owner and callers with USE_RECIPIENT, who may read anyone's
+ * recipients, are meant to list and get recipients.
+ */
+@Blocking
+@ProducesJson
+public final class RecipientAdminService {
+
+ private final RecipientService recipients;
+ private final String activationBaseUrl;
+
+ /**
+ * @param activationBaseUrl the absolute URL recipient activation links are built under, on the
+ * address recipients reach the host at
+ */
+ public RecipientAdminService(RecipientService recipients, String activationBaseUrl) {
+ this.recipients = recipients;
+ this.activationBaseUrl = activationBaseUrl;
+ }
+
+ /**
+ * {@code POST /recipients}: creates a recipient owned by the caller, with a new activation code.
+ * Needs CREATE_RECIPIENT.
+ */
+ @Post("/recipients")
+ @StatusCode(201)
+ public RecipientResponse create(ServiceRequestContext ctx, CreateRecipientRequest request) {
+ return recipients.create(ProviderTokenDecorator.user(ctx), request, activationBaseUrl);
+ }
+
+ /** {@code GET /recipients}: lists every recipient by name, unpaged. */
+ @Get("/recipients")
+ public ListResponse list() {
+ return recipients.list(activationBaseUrl);
+ }
+
+ /** {@code GET /recipients/{recipient}}: gets a recipient by name in any case. */
+ @Get("/recipients/{recipient}")
+ public RecipientResponse get(@Param String recipient) {
+ return recipients.get(recipient, activationBaseUrl);
+ }
+
+ /** {@code PATCH /recipients/{recipient}}: updates the fields set in the body. Owner only. */
+ @Patch("/recipients/{recipient}")
+ public RecipientResponse update(
+ ServiceRequestContext ctx, @Param String recipient, UpdateRecipientRequest request) {
+ return recipients.update(
+ ProviderTokenDecorator.user(ctx), recipient, request, activationBaseUrl);
+ }
+
+ /** {@code DELETE /recipients/{recipient}}: deletes the recipient and its tokens. Owner only. */
+ @Delete("/recipients/{recipient}")
+ public HttpResponse delete(ServiceRequestContext ctx, @Param String recipient) {
+ recipients.delete(ProviderTokenDecorator.user(ctx), recipient);
+ return HttpResponse.of(HttpStatus.NO_CONTENT);
+ }
+}
diff --git a/server/opensharing-server/src/main/resources/server.properties b/server/opensharing-server/src/main/resources/server.properties
index e6e5c2a0..d311fc37 100644
--- a/server/opensharing-server/src/main/resources/server.properties
+++ b/server/opensharing-server/src/main/resources/server.properties
@@ -5,9 +5,14 @@
# HTTP port the server listens on; 0 picks a free one.
server.port=8080
+# Address recipients reach this server at, such as https://sharing.example.com. Required:
+# activation links and the endpoint in profile files are built on it.
+server.public-url=
# Path prefix of the provider API, whose bearer tokens the catalog authenticates.
provider.base-path=/api/1.0/opensharing/provider
+# Path prefix recipient activation links are built under, after server.public-url.
+activation.base-path=/api/1.0/opensharing/activations
# Which CatalogConnector the server uses; local reads assets from a YAML file.
catalog.type=local
diff --git a/server/opensharing-server/src/test/java/io/opensharing/OpenSharingServerTest.java b/server/opensharing-server/src/test/java/io/opensharing/OpenSharingServerTest.java
index 9a839d20..48a3bfa6 100644
--- a/server/opensharing-server/src/test/java/io/opensharing/OpenSharingServerTest.java
+++ b/server/opensharing-server/src/test/java/io/opensharing/OpenSharingServerTest.java
@@ -70,6 +70,7 @@ void rejectsProgramArguments() {
private static ServerProperties properties(String... overrides) {
Properties props = new Properties();
props.setProperty("server.port", "0");
+ props.setProperty("server.public-url", "https://sharing.example.com");
for (int i = 0; i < overrides.length; i += 2) {
props.setProperty(overrides[i], overrides[i + 1]);
}
diff --git a/server/opensharing-server/src/test/java/io/opensharing/PublicUrlTest.java b/server/opensharing-server/src/test/java/io/opensharing/PublicUrlTest.java
new file mode 100644
index 00000000..42c6ef73
--- /dev/null
+++ b/server/opensharing-server/src/test/java/io/opensharing/PublicUrlTest.java
@@ -0,0 +1,80 @@
+package io.opensharing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpMethod;
+import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.common.RequestHeaders;
+import io.opensharing.config.ServerProperties;
+import java.util.Properties;
+import org.junit.jupiter.api.Test;
+
+class PublicUrlTest {
+
+ @Test
+ void requiresAPublicUrlToStart() {
+ Properties props = new Properties();
+ props.setProperty("server.port", "0");
+
+ IllegalArgumentException missing =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> OpenSharingServer.start(new ServerProperties(props)));
+ assertEquals(
+ "server.public-url is required; set it to the address recipients reach this server at",
+ missing.getMessage());
+ }
+
+ @Test
+ void acceptsOnlyHttpUrlsAndDropsTrailingSlashes() {
+ assertEquals(
+ "https://sharing.example.com/delta", publicUrl("https://sharing.example.com/delta/"));
+ for (String invalid :
+ new String[] {"sharing.example.com", "ftp://sharing.example.com", "https://x.com/?a=b"}) {
+ IllegalArgumentException rejected =
+ assertThrows(IllegalArgumentException.class, () -> publicUrl(invalid));
+ assertEquals(
+ "invalid value '" + invalid + "' for server.public-url; expected an http or https URL",
+ rejected.getMessage());
+ }
+ }
+
+ @Test
+ void buildsActivationLinksOnThePublicUrlRatherThanTheRequestAddress() throws Exception {
+ Properties props = new Properties();
+ props.setProperty("server.port", "0");
+ props.setProperty("server.public-url", "https://sharing.example.com/");
+ try (OpenSharingServer server = OpenSharingServer.start(new ServerProperties(props))) {
+ AggregatedHttpResponse response =
+ WebClient.of("http://127.0.0.1:" + server.port())
+ .execute(
+ RequestHeaders.builder(
+ HttpMethod.POST, "/api/1.0/opensharing/provider/recipients")
+ .contentType(MediaType.JSON)
+ .add("authorization", "Bearer alice-token")
+ .build(),
+ "{\"name\":\"acme\",\"authenticationType\":\"TOKEN\"}")
+ .aggregate()
+ .join();
+
+ assertEquals(201, response.status().code());
+ JsonNode body = new ObjectMapper().readTree(response.contentUtf8());
+ assertTrue(
+ body.path("activationUrl")
+ .asText()
+ .startsWith("https://sharing.example.com/api/1.0/opensharing/activations/"));
+ }
+ }
+
+ private static String publicUrl(String value) {
+ Properties props = new Properties();
+ props.setProperty("server.public-url", value);
+ return new ServerProperties(props).getPublicUrl();
+ }
+}
diff --git a/server/opensharing-server/src/test/java/io/opensharing/TestServer.java b/server/opensharing-server/src/test/java/io/opensharing/TestServer.java
index ec381911..bc085aae 100644
--- a/server/opensharing-server/src/test/java/io/opensharing/TestServer.java
+++ b/server/opensharing-server/src/test/java/io/opensharing/TestServer.java
@@ -15,6 +15,7 @@
import io.opensharing.config.ServerProperties;
import java.io.IOException;
import java.io.UncheckedIOException;
+import java.net.ServerSocket;
import java.util.Properties;
/** A standalone server on a free port, and a client that calls it as a provider. */
@@ -30,12 +31,23 @@ private TestServer(OpenSharingServer server) {
this.client = WebClient.of("http://127.0.0.1:" + server.port());
}
+ /** Starts on a free port that is also the public URL, so links in responses point back to it. */
public static TestServer start() {
+ int port = freePort();
Properties props = new Properties();
- props.setProperty("server.port", "0");
+ props.setProperty("server.port", Integer.toString(port));
+ props.setProperty("server.public-url", "http://127.0.0.1:" + port);
return new TestServer(OpenSharingServer.start(new ServerProperties(props)));
}
+ private static int freePort() {
+ try (ServerSocket socket = new ServerSocket(0)) {
+ return socket.getLocalPort();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
public OpenSharingServer server() {
return server;
}
diff --git a/server/opensharing-server/src/test/java/io/opensharing/http/UnknownRouteTest.java b/server/opensharing-server/src/test/java/io/opensharing/http/UnknownRouteTest.java
index d9aae590..72def240 100644
--- a/server/opensharing-server/src/test/java/io/opensharing/http/UnknownRouteTest.java
+++ b/server/opensharing-server/src/test/java/io/opensharing/http/UnknownRouteTest.java
@@ -17,6 +17,7 @@ class UnknownRouteTest {
void answersUnknownRoutesWithTheProtocolErrorBody() throws Exception {
Properties props = new Properties();
props.setProperty("server.port", "0");
+ props.setProperty("server.public-url", "https://sharing.example.com");
try (OpenSharingServer server = OpenSharingServer.start(new ServerProperties(props))) {
WebClient client = WebClient.of("http://127.0.0.1:" + server.port());
AggregatedHttpResponse response = client.get("/no-such-route").aggregate().join();
diff --git a/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientAdminServiceTest.java b/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientAdminServiceTest.java
new file mode 100644
index 00000000..d14c248b
--- /dev/null
+++ b/server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientAdminServiceTest.java
@@ -0,0 +1,89 @@
+package io.opensharing.recipient;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.opensharing.TestServer;
+import io.opensharing.TestServer.Response;
+import io.opensharing.http.ErrorCodes;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class RecipientAdminServiceTest {
+
+ private static final String RECIPIENTS = "/api/1.0/opensharing/provider/recipients";
+
+ private static TestServer server;
+ private static String activations;
+
+ @BeforeAll
+ static void startServer() {
+ server = TestServer.start();
+ activations =
+ "http://127.0.0.1:" + server.server().port() + "/api/1.0/opensharing/activations/";
+ }
+
+ @AfterAll
+ static void stopServer() {
+ server.close();
+ }
+
+ @Test
+ void requiresACatalogAuthorizedPrincipal() {
+ // Missing bearer token is unauthenticated.
+ Response missing = server.get(RECIPIENTS, null);
+ assertEquals(401, missing.status());
+ assertEquals(ErrorCodes.UNAUTHENTICATED, missing.errorCode());
+ }
+
+ @Test
+ void createsListsUpdatesAndDeletesARecipient() {
+ // Owner creates a TOKEN recipient and gets an activation URL.
+ Response created =
+ server.post(
+ RECIPIENTS,
+ "alice-token",
+ "{\"name\":\"Acme\",\"comment\":\"partner\",\"authenticationType\":\"TOKEN\"}");
+ assertEquals(201, created.status());
+ assertEquals("acme", created.body().path("name").asText());
+ assertEquals("TOKEN", created.body().path("authenticationType").asText());
+ assertTrue(created.body().path("activationUrl").asText().startsWith(activations));
+ assertFalse(created.body().path("id").isMissingNode());
+
+ // Owner lists recipients including activation URLs.
+ Response list = server.get(RECIPIENTS, "alice-token");
+ assertEquals(200, list.status());
+ assertEquals("acme", list.body().path("items").get(0).path("name").asText());
+ assertTrue(
+ list.body().path("items").get(0).path("activationUrl").asText().startsWith(activations));
+
+ // Get by name is case-insensitive and allowed for any catalog principal.
+ Response fetched = server.get(RECIPIENTS + "/ACME", "bob-token");
+ assertEquals(200, fetched.status());
+ assertEquals("acme", fetched.body().path("name").asText());
+ assertEquals("partner", fetched.body().path("comment").asText());
+ assertEquals("TOKEN", fetched.body().path("authenticationType").asText());
+ assertTrue(fetched.body().path("activationUrl").asText().startsWith(activations));
+
+ // Non-owner cannot update.
+ Response stolen = server.patch(RECIPIENTS + "/acme", "bob-token", "{\"comment\":\"stolen\"}");
+ assertEquals(403, stolen.status());
+ assertEquals(ErrorCodes.PERMISSION_DENIED, stolen.errorCode());
+
+ // Owner updates the comment.
+ Response updated =
+ server.patch(RECIPIENTS + "/acme", "alice-token", "{\"comment\":\"updated\"}");
+ assertEquals(200, updated.status());
+ assertEquals("updated", updated.body().path("comment").asText());
+
+ // Owner deletes the recipient.
+ assertEquals(204, server.delete(RECIPIENTS + "/acme", "alice-token").status());
+
+ // Deleted recipient is 404.
+ Response deleted = server.get(RECIPIENTS + "/acme", "alice-token");
+ assertEquals(404, deleted.status());
+ assertEquals(ErrorCodes.RESOURCE_DOES_NOT_EXIST, deleted.errorCode());
+ }
+}