From c12f64c21b34703635be759028680b5a11425579 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 13 Oct 2026 16:35:36 -0700 Subject: [PATCH 1/4] Add provider-admin recipient CRUD, served by Armeria. --- .../recipient/AuthenticationType.java | 6 + .../recipient/CreateRecipientRequest.java | 8 ++ .../recipient/RecipientEntity.java | 68 +++++++++ .../recipient/RecipientResponse.java | 35 +++++ .../recipient/RecipientService.java | 84 +++++++++++ .../opensharing/recipient/RecipientStore.java | 132 ++++++++++++++++++ .../recipient/RecipientTokenEntity.java | 46 ++++++ .../recipient/UpdateRecipientRequest.java | 4 + .../io/opensharing/runtime/OpenSharing.java | 16 ++- .../recipient/RecipientServiceTest.java | 52 +++++++ .../io/opensharing/OpenSharingServer.java | 13 +- .../opensharing/config/ServerProperties.java | 42 +++++- .../recipient/RecipientAdminService.java | 74 ++++++++++ .../src/main/resources/server.properties | 5 + .../io/opensharing/OpenSharingServerTest.java | 1 + .../java/io/opensharing/PublicUrlTest.java | 80 +++++++++++ .../test/java/io/opensharing/TestServer.java | 14 +- .../io/opensharing/http/UnknownRouteTest.java | 1 + .../recipient/RecipientAdminServiceTest.java | 89 ++++++++++++ 19 files changed, 766 insertions(+), 4 deletions(-) create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/recipient/AuthenticationType.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/recipient/CreateRecipientRequest.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientEntity.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientResponse.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientService.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientTokenEntity.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/recipient/UpdateRecipientRequest.java create mode 100644 server/opensharing-server-core/src/test/java/io/opensharing/recipient/RecipientServiceTest.java create mode 100644 server/opensharing-server/src/main/java/io/opensharing/recipient/RecipientAdminService.java create mode 100644 server/opensharing-server/src/test/java/io/opensharing/PublicUrlTest.java create mode 100644 server/opensharing-server/src/test/java/io/opensharing/recipient/RecipientAdminServiceTest.java 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 000000000..2a1fea985 --- /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 000000000..20583d704 --- /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 000000000..37be07afa --- /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 000000000..e897fb872 --- /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 000000000..28d6bd553 --- /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.require(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 000000000..0aff89b77 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/recipient/RecipientStore.java @@ -0,0 +1,132 @@ +package io.opensharing.recipient; + +import io.opensharing.ObjectNames; +import io.opensharing.Transactions; +import io.opensharing.auth.UserContext; +import io.opensharing.http.ApiException; +import jakarta.persistence.EntityManager; +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).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 = requireOwned(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 require(String name) { + return tx.inTransaction(true, em -> require(em, name)); + } + + /** 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)); + } + + /** {@code SELECT * FROM os_recipients ORDER BY name ASC}: every recipient, of any owner. */ + 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 = requireOwned(em, name, user); + // DELETE FROM os_recipient_tokens WHERE recipient_id = ? + em.createQuery("delete from RecipientTokenEntity t where t.recipient.id = :recipientId") + .setParameter("recipientId", recipient.getId()) + .executeUpdate(); + em.remove(recipient); + return null; + }); + } + + /** {@code SELECT * FROM os_recipients WHERE name = ?} */ + private static Optional find(EntityManager em, String name) { + return em.createQuery( + "select r from RecipientEntity r where r.name = :name", RecipientEntity.class) + .setParameter("name", ObjectNames.normalize(name)) + .getResultStream() + .findFirst(); + } + + private static RecipientEntity require(EntityManager em, String name) { + return find(em, name) + .orElseThrow(() -> ApiException.notFound("recipient '" + name + "' does not exist")); + } + + private static RecipientEntity requireOwned(EntityManager em, String name, UserContext user) { + RecipientEntity recipient = require(em, name); + user.requireOwner(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 000000000..a7c8dce35 --- /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 000000000..52dcbc87a --- /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 dd495b8a3..f3bd4d146 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 000000000..fd6c44c7a --- /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 e4dd6c7d0..3bdb4fe56 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 633f5719c..3bf221c23 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 000000000..44145edc8 --- /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 e6e5c2a0d..d311fc376 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 9a839d20c..48a3bfa69 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 000000000..42c6ef736 --- /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 ec381911f..bc085aaec 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 d9aae590f..72def2407 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 000000000..d14c248bf --- /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()); + } +} From f45043c5274c98a5736ea62edea2a800497b4844 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 19 Oct 2026 14:05:07 -0700 Subject: [PATCH 2/4] Lock a recipient's row for owner changes, so concurrent changes to one recipient run one at a time. --- .../opensharing/recipient/RecipientStore.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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 0aff89b77..aad37fbfc 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 @@ -5,6 +5,7 @@ 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; @@ -34,7 +35,7 @@ public RecipientEntity create( return tx.inTransaction( false, em -> { - if (find(em, name).isPresent()) { + if (find(em, name, LockModeType.NONE).isPresent()) { throw ApiException.alreadyExists("recipient '" + name + "' already exists"); } RecipientEntity recipient = new RecipientEntity(); @@ -66,7 +67,7 @@ public RecipientEntity update(UserContext user, String name, String comment) { /** Looks up a recipient by name in any case. Fails with not-found when it does not exist. */ public RecipientEntity require(String name) { - return tx.inTransaction(true, em -> require(em, name)); + return tx.inTransaction(true, em -> require(em, name, LockModeType.NONE)); } /** The newest token's activation code, or null when there is none. */ @@ -110,22 +111,27 @@ public void delete(String name, UserContext user) { }); } - /** {@code SELECT * FROM os_recipients WHERE name = ?} */ - private static Optional find(EntityManager em, String name) { + /** {@code SELECT * FROM os_recipients WHERE name = ?}, plus {@code FOR UPDATE} under a lock. */ + 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 require(EntityManager em, String name) { - return find(em, name) + private static RecipientEntity require(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 requireOwned(EntityManager em, String name, UserContext user) { - RecipientEntity recipient = require(em, name); + RecipientEntity recipient = require(em, name, LockModeType.PESSIMISTIC_WRITE); user.requireOwner(recipient.getOwnerId(), "recipient '" + recipient.getName() + "'"); return recipient; } From d67256b8182848a08d71126878dc3045b8862893 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 19 Oct 2026 16:53:08 -0700 Subject: [PATCH 3/4] Use getOrThrow, getOwnedOrThrow and Ownership.check for recipients. --- .../opensharing/recipient/RecipientService.java | 2 +- .../opensharing/recipient/RecipientStore.java | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) 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 28d6bd553..e5a2775f5 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 @@ -60,7 +60,7 @@ public ListResponse list(String activationBaseUrl) { /** 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.require(recipient), activationBaseUrl); + return toResponse(recipients.getOrThrow(recipient), activationBaseUrl); } /** Updates the fields set in {@code request}. Owner only. */ 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 aad37fbfc..f4f488259 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 @@ -2,6 +2,7 @@ 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; @@ -57,7 +58,7 @@ public RecipientEntity update(UserContext user, String name, String comment) { return tx.inTransaction( false, em -> { - RecipientEntity recipient = requireOwned(em, name, user); + RecipientEntity recipient = getOwnedOrThrow(em, name, user); if (comment != null) { recipient.setComment(comment); } @@ -66,8 +67,8 @@ public RecipientEntity update(UserContext user, String name, String comment) { } /** Looks up a recipient by name in any case. Fails with not-found when it does not exist. */ - public RecipientEntity require(String name) { - return tx.inTransaction(true, em -> require(em, name, LockModeType.NONE)); + 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. */ @@ -101,7 +102,7 @@ public void delete(String name, UserContext user) { tx.inTransaction( false, em -> { - RecipientEntity recipient = requireOwned(em, name, user); + RecipientEntity recipient = getOwnedOrThrow(em, name, user); // DELETE FROM os_recipient_tokens WHERE recipient_id = ? em.createQuery("delete from RecipientTokenEntity t where t.recipient.id = :recipientId") .setParameter("recipientId", recipient.getId()) @@ -121,7 +122,7 @@ private static Optional find(EntityManager em, String name, Loc .findFirst(); } - private static RecipientEntity require(EntityManager em, String name, LockModeType lock) { + private static RecipientEntity getOrThrow(EntityManager em, String name, LockModeType lock) { return find(em, name, lock) .orElseThrow(() -> ApiException.notFound("recipient '" + name + "' does not exist")); } @@ -130,9 +131,9 @@ private static RecipientEntity require(EntityManager em, String name, LockModeTy * 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 requireOwned(EntityManager em, String name, UserContext user) { - RecipientEntity recipient = require(em, name, LockModeType.PESSIMISTIC_WRITE); - user.requireOwner(recipient.getOwnerId(), "recipient '" + recipient.getName() + "'"); + 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; } } From aae7d695805456bea750ab42c4e7a8ec2b64ed6e Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Mon, 19 Oct 2026 16:58:49 -0700 Subject: [PATCH 4/4] Describe store queries in prose rather than SQL. --- .../main/java/io/opensharing/recipient/RecipientStore.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 f4f488259..223b88972 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 @@ -87,7 +87,7 @@ public String findActivationCode(RecipientEntity recipient) { .orElse(null)); } - /** {@code SELECT * FROM os_recipients ORDER BY name ASC}: every recipient, of any owner. */ + /** Every recipient, regardless of owner, ordered by name. */ public List list() { return tx.inTransaction( true, @@ -103,7 +103,6 @@ public void delete(String name, UserContext user) { false, em -> { RecipientEntity recipient = getOwnedOrThrow(em, name, user); - // DELETE FROM os_recipient_tokens WHERE recipient_id = ? em.createQuery("delete from RecipientTokenEntity t where t.recipient.id = :recipientId") .setParameter("recipientId", recipient.getId()) .executeUpdate(); @@ -112,7 +111,6 @@ public void delete(String name, UserContext user) { }); } - /** {@code SELECT * FROM os_recipients WHERE name = ?}, plus {@code FOR UPDATE} under a lock. */ private static Optional find(EntityManager em, String name, LockModeType lock) { return em.createQuery( "select r from RecipientEntity r where r.name = :name", RecipientEntity.class)