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()); + } +}