diff --git a/features/bsm/rest/impl/pom.xml b/features/bsm/rest/impl/pom.xml index 1ad5aeeaf89e..3df24ca3c11b 100644 --- a/features/bsm/rest/impl/pom.xml +++ b/features/bsm/rest/impl/pom.xml @@ -64,6 +64,21 @@ ${project.version} provided + + + org.opennms + org.opennms.features.topology-assets + ${project.version} + test + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.json.postgres + ${project.version} + test + org.opennms.dependencies cxf-dependencies diff --git a/features/pom.xml b/features/pom.xml index b58aa90dfd7e..e3c2b9078f3c 100644 --- a/features/pom.xml +++ b/features/pom.xml @@ -20,6 +20,9 @@ topologies + + topology-assets + activemq diff --git a/features/status/rest/pom.xml b/features/status/rest/pom.xml index d4c5b952ac9f..396cbc77f98e 100644 --- a/features/status/rest/pom.xml +++ b/features/status/rest/pom.xml @@ -55,6 +55,21 @@ ${project.version} test + + + org.opennms + org.opennms.features.topology-assets + ${project.version} + test + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.json.postgres + ${project.version} + test + org.opennms.features.bsm org.opennms.features.bsm.service.impl diff --git a/features/topology-assets/pom.xml b/features/topology-assets/pom.xml new file mode 100644 index 000000000000..f03ab458e88b --- /dev/null +++ b/features/topology-assets/pom.xml @@ -0,0 +1,105 @@ + + + + org.opennms + org.opennms.features + 36.0.2-SNAPSHOT + + 4.0.0 + org.opennms.features.topology-assets + bundle + OpenNMS :: Features :: Topology Assets + + Storage for images referenced by custom topology views: background + images (floor plans, rack diagrams) and custom node icons. Bytes are + kept in the generic binary key-value store and the catalog metadata + in the JSON key-value store; this module owns the model and DAO. The + JAX-RS resource that exposes /api/v2/topology/assets lives in + opennms-webapp-rest. + + + + + org.apache.felix + maven-bundle-plugin + true + + + JavaSE-1.8 + ${project.artifactId} + ${project.version} + + + + + + + + org.opennms.dependencies + spring-dependencies + pom + provided + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.api + ${project.version} + provided + + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.blob.postgres + ${project.version} + + + com.google.code.gson + gson + ${gsonVersion} + provided + + + org.slf4j + slf4j-api + provided + + + com.google.guava + guava + + + junit + junit + test + + + org.hamcrest + hamcrest-library + test + + + org.mockito + mockito-core + test + + + org.opennms.core.test-api + org.opennms.core.test-api.lib + test + + + org.opennms.core.test-api + org.opennms.core.test-api.db + test + + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.json.postgres + ${project.version} + test + + + diff --git a/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAsset.java b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAsset.java new file mode 100644 index 000000000000..cffa37fd13d8 --- /dev/null +++ b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAsset.java @@ -0,0 +1,132 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.topology.assets; + +import java.util.Date; + +import com.google.common.base.MoreObjects; + +/** + * Catalog metadata for an image used by custom topology views: a background + * (floor plan, rack diagram) or a custom node icon. + * + *

This is the metadata only — the image bytes are stored separately in the + * binary key-value store and served by id (see {@code TopologyAssetKvStore}). + * Views reference an asset by its {@link #getId() id} from their (opaque) + * definition document, e.g. a background's {@code ref} or a node's icon. + * + *

{@link #getKind() kind} distinguishes the intended use + * ({@code background} vs {@code icon}) so pickers can list only what fits and + * the upload path can apply a size limit appropriate to each. Assets are a + * shared catalog like the views themselves; {@link #getOwner() owner} records + * who uploaded it (informational). + */ +public class TopologyAsset { + + public static final String KIND_BACKGROUND = "background"; + public static final String KIND_ICON = "icon"; + + private String id; + private String name; + private String kind; + private String mimeType; + private long sizeBytes; + private String owner; + private Date created; + private Date lastModified; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public long getSizeBytes() { + return sizeBytes; + } + + public void setSizeBytes(long sizeBytes) { + this.sizeBytes = sizeBytes; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getLastModified() { + return lastModified; + } + + public void setLastModified(Date lastModified) { + this.lastModified = lastModified; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("name", name) + .add("kind", kind) + .add("mimeType", mimeType) + .add("sizeBytes", sizeBytes) + .add("owner", owner) + .toString(); + } +} diff --git a/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAssetDao.java b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAssetDao.java new file mode 100644 index 000000000000..a02c178480ad --- /dev/null +++ b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/TopologyAssetDao.java @@ -0,0 +1,61 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.topology.assets; + +import java.util.List; +import java.util.Optional; + +/** + * Access to the shared catalog of topology image assets (backgrounds and + * custom node icons). Metadata and bytes are stored and fetched separately so + * listings never drag image payloads along. + * + *

Validation (allowed MIME types, size limits per kind) is the caller's + * concern — the REST layer enforces it; this store is format-agnostic. + */ +public interface TopologyAssetDao { + + /** + * Stores a new asset. The id, byte size, and timestamps are assigned here; + * everything else (name, kind, MIME type, owner) comes filled in on + * {@code asset}. + * + * @return the stored metadata, with the generated id set. + */ + TopologyAsset save(TopologyAsset asset, byte[] bytes); + + /** The metadata for an asset, if it exists. */ + Optional get(String id); + + /** The image bytes for an asset, if it exists. */ + Optional getBytes(String id); + + /** All assets' metadata (never the bytes). */ + List findAll(); + + /** + * Removes an asset (metadata and bytes). + * + * @return whether an asset with that id existed. + */ + boolean delete(String id); +} diff --git a/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java new file mode 100644 index 000000000000..ebe76f6a7f62 --- /dev/null +++ b/features/topology-assets/src/main/java/org/opennms/netmgt/topology/assets/impl/TopologyAssetKvStore.java @@ -0,0 +1,191 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.topology.assets.impl; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import javax.sql.DataSource; + +import org.opennms.features.distributed.kvstore.api.BlobStore; +import org.opennms.features.distributed.kvstore.api.JsonStore; +import org.opennms.features.distributed.kvstore.blob.postgres.PostgresBlobStore; +import org.opennms.netmgt.topology.assets.TopologyAsset; +import org.opennms.netmgt.topology.assets.TopologyAssetDao; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * Stores topology image assets in the generic key-value stores: the image + * bytes in the binary store ({@link BlobStore}, the {@code kvstore_bytea} + * table) and the catalog metadata as a JSON document in the {@link JsonStore} + * ({@code kvstore_jsonb}), both under the {@value #CONTEXT} context and keyed + * by the same generated UUID. Splitting the two keeps catalog listings cheap + * — enumerating metadata never touches the image payloads. + * + *

The {@link BlobStore} is constructed here from the {@link DataSource} + * rather than injected: the only BlobStore exposed as a shared bean in the + * core context is the no-op used by thresholding, and registering a second + * BlobStore service would make that lookup ambiguous for its consumers. + * Assets are a core-webapp feature, always backed by the OpenNMS database. + */ +public class TopologyAssetKvStore implements TopologyAssetDao { + + private static final Logger LOG = LoggerFactory.getLogger(TopologyAssetKvStore.class); + + static final String CONTEXT = "topology-assets"; + + private final JsonStore jsonStore; + private final BlobStore blobStore; + private final Gson gson = new Gson(); + + public TopologyAssetKvStore(final JsonStore jsonStore, final DataSource dataSource) { + this(jsonStore, new PostgresBlobStore(Objects.requireNonNull(dataSource))); + } + + /** For tests: inject the blob store directly. */ + TopologyAssetKvStore(final JsonStore jsonStore, final BlobStore blobStore) { + this.jsonStore = Objects.requireNonNull(jsonStore); + this.blobStore = Objects.requireNonNull(blobStore); + } + + @Override + public TopologyAsset save(final TopologyAsset asset, final byte[] bytes) { + Objects.requireNonNull(asset); + Objects.requireNonNull(bytes); + final Date now = new Date(); + asset.setId(UUID.randomUUID().toString()); + asset.setSizeBytes(bytes.length); + asset.setCreated(now); + asset.setLastModified(now); + // Bytes first: if the metadata write fails the orphaned blob is + // invisible (nothing lists it) and gets overwritten only by its own + // UUID key; the reverse order could list an asset with no bytes. + blobStore.put(asset.getId(), bytes, CONTEXT); + jsonStore.put(asset.getId(), gson.toJson(Doc.of(asset)), CONTEXT); + return asset; + } + + @Override + public Optional get(final String id) { + if (id == null) { + return Optional.empty(); + } + // flatMap + ofNullable: a malformed stored document (deserialize -> null) + // reads as absent rather than surfacing a half-built asset. + return jsonStore.get(id, CONTEXT).flatMap(json -> Optional.ofNullable(deserialize(id, json))); + } + + @Override + public Optional getBytes(final String id) { + if (id == null) { + return Optional.empty(); + } + return blobStore.get(id, CONTEXT); + } + + @Override + public List findAll() { + final Map entries = jsonStore.enumerateContext(CONTEXT); + if (entries == null || entries.isEmpty()) { + return new ArrayList<>(); + } + final List assets = new ArrayList<>(entries.size()); + for (final Map.Entry entry : entries.entrySet()) { + final TopologyAsset asset = deserialize(entry.getKey(), entry.getValue()); + if (asset != null) { + assets.add(asset); + } + } + return assets; + } + + @Override + public boolean delete(final String id) { + if (id == null || !jsonStore.get(id, CONTEXT).isPresent()) { + return false; + } + jsonStore.delete(id, CONTEXT); + blobStore.delete(id, CONTEXT); + return true; + } + + private TopologyAsset deserialize(final String id, final String json) { + try { + final Doc doc = gson.fromJson(json, Doc.class); + if (doc == null) { + return null; + } + return doc.toAsset(id); + } catch (final JsonSyntaxException e) { + LOG.warn("Skipping malformed topology asset document for key {}: {}", id, e.getMessage()); + return null; + } + } + + /** + * The stored metadata document. Timestamps travel as epoch millis so the + * JSON shape doesn't depend on gson's default (locale-bound) Date format. + */ + private static final class Doc { + String name; + String kind; + String mimeType; + long sizeBytes; + String owner; + Long created; + Long lastModified; + + static Doc of(final TopologyAsset asset) { + final Doc doc = new Doc(); + doc.name = asset.getName(); + doc.kind = asset.getKind(); + doc.mimeType = asset.getMimeType(); + doc.sizeBytes = asset.getSizeBytes(); + doc.owner = asset.getOwner(); + doc.created = asset.getCreated() == null ? null : asset.getCreated().getTime(); + doc.lastModified = asset.getLastModified() == null ? null : asset.getLastModified().getTime(); + return doc; + } + + TopologyAsset toAsset(final String id) { + final TopologyAsset asset = new TopologyAsset(); + asset.setId(id); + asset.setName(name); + asset.setKind(kind); + asset.setMimeType(mimeType); + asset.setSizeBytes(sizeBytes); + asset.setOwner(owner); + asset.setCreated(created == null ? null : new Date(created)); + asset.setLastModified(lastModified == null ? null : new Date(lastModified)); + return asset; + } + } +} diff --git a/features/topology-assets/src/main/resources/META-INF/opennms/component-dao.xml b/features/topology-assets/src/main/resources/META-INF/opennms/component-dao.xml new file mode 100644 index 000000000000..49f1b7a5487a --- /dev/null +++ b/features/topology-assets/src/main/resources/META-INF/opennms/component-dao.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/features/topology-assets/src/test/java/org/opennms/netmgt/topology/assets/TopologyAssetDaoIT.java b/features/topology-assets/src/test/java/org/opennms/netmgt/topology/assets/TopologyAssetDaoIT.java new file mode 100644 index 000000000000..35957e23f5cd --- /dev/null +++ b/features/topology-assets/src/test/java/org/opennms/netmgt/topology/assets/TopologyAssetDaoIT.java @@ -0,0 +1,140 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.topology.assets; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Optional; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.db.DataSourceFactory; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.features.distributed.kvstore.api.JsonStore; +import org.opennms.netmgt.topology.assets.impl.TopologyAssetKvStore; +import org.opennms.test.JUnitConfigurationEnvironment; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; + +/** + * Exercises the {@link TopologyAssetDao} against a real (temporary) database: + * metadata through the {@code PostgresJsonStore} ({@code kvstore_jsonb}) and + * bytes through a {@code PostgresBlobStore} ({@code kvstore_bytea}) — the same + * pairing the production wiring builds. + */ +@RunWith(OpenNMSJUnit4ClassRunner.class) +@ContextConfiguration(locations = { + "classpath:/META-INF/opennms/applicationContext-soa.xml", + "classpath:/META-INF/opennms/applicationContext-postgresJsonStore.xml" +}) +@JUnitConfigurationEnvironment +@JUnitTemporaryDatabase +public class TopologyAssetDaoIT { + + @Autowired + private JsonStore jsonStore; + + private TopologyAssetDao dao; + + private static final byte[] PNG_ISH = "not-really-a-png-but-bytes".getBytes(StandardCharsets.UTF_8); + + @Before + public void setUp() { + // Same construction as the production component-dao wiring: the blob + // store is built from the (here: temporary) datasource internally. + dao = new TopologyAssetKvStore(jsonStore, DataSourceFactory.getInstance()); + } + + private static TopologyAsset background(final String name) { + final TopologyAsset asset = new TopologyAsset(); + asset.setName(name); + asset.setKind(TopologyAsset.KIND_BACKGROUND); + asset.setMimeType("image/png"); + asset.setOwner("admin"); + return asset; + } + + @Test + @JUnitTemporaryDatabase + public void roundTripsMetadataAndBytes() { + assertThat(dao.findAll(), hasSize(0)); + + final TopologyAsset saved = dao.save(background("dc floor plan"), PNG_ISH); + assertNotNull(saved.getId()); + assertThat(saved.getSizeBytes(), is((long) PNG_ISH.length)); + assertNotNull(saved.getCreated()); + assertNotNull(saved.getLastModified()); + + final Optional read = dao.get(saved.getId()); + assertTrue(read.isPresent()); + assertThat(read.get().getName(), is("dc floor plan")); + assertThat(read.get().getKind(), is(TopologyAsset.KIND_BACKGROUND)); + assertThat(read.get().getMimeType(), is("image/png")); + assertThat(read.get().getOwner(), is("admin")); + assertThat(read.get().getSizeBytes(), is((long) PNG_ISH.length)); + assertThat(read.get().getCreated(), is(saved.getCreated())); + + final Optional bytes = dao.getBytes(saved.getId()); + assertTrue(bytes.isPresent()); + assertArrayEquals(PNG_ISH, bytes.get()); + } + + @Test + @JUnitTemporaryDatabase + public void listsMetadataForAllAssets() { + dao.save(background("a"), PNG_ISH); + final TopologyAsset icon = new TopologyAsset(); + icon.setName("router glyph"); + icon.setKind(TopologyAsset.KIND_ICON); + icon.setMimeType("image/webp"); + dao.save(icon, PNG_ISH); + + final List all = dao.findAll(); + assertThat(all, hasSize(2)); + assertTrue(all.stream().anyMatch(a -> TopologyAsset.KIND_ICON.equals(a.getKind()))); + assertTrue(all.stream().anyMatch(a -> TopologyAsset.KIND_BACKGROUND.equals(a.getKind()))); + } + + @Test + @JUnitTemporaryDatabase + public void deleteRemovesMetadataAndBytes() { + final TopologyAsset saved = dao.save(background("doomed"), PNG_ISH); + + assertTrue(dao.delete(saved.getId())); + assertFalse(dao.get(saved.getId()).isPresent()); + assertFalse(dao.getBytes(saved.getId()).isPresent()); + + // Deleting again (or a never-existing id) reports false, not an error. + assertFalse(dao.delete(saved.getId())); + assertFalse(dao.delete("no-such-asset")); + } +} diff --git a/opennms-base-assembly/pom.xml b/opennms-base-assembly/pom.xml index 77798cd884b4..84b2e101bbcc 100644 --- a/opennms-base-assembly/pom.xml +++ b/opennms-base-assembly/pom.xml @@ -1199,6 +1199,19 @@ org.opennms.features.enlinkd.service.impl ${project.version} + + + org.opennms + org.opennms.features.topology-assets + ${project.version} + + + org.opennms.features.distributed + org.opennms.features.distributed.kv-store.blob.postgres + ${project.version} + org.opennms.features.enlinkd org.opennms.features.enlinkd.daemon diff --git a/opennms-webapp-rest/pom.xml b/opennms-webapp-rest/pom.xml index b41e9b24e340..6db3622c5be3 100644 --- a/opennms-webapp-rest/pom.xml +++ b/opennms-webapp-rest/pom.xml @@ -237,6 +237,12 @@ org.opennms.features.enlinkd.service.api ${onmsLibScope} + + org.opennms + org.opennms.features.topology-assets + ${project.version} + ${onmsLibScope} + org.opennms opennms-util diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetDTO.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetDTO.java new file mode 100644 index 000000000000..12f23895ae2b --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetDTO.java @@ -0,0 +1,104 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import java.util.Date; + +/** + * Wire shape of a topology image asset's metadata (the bytes are served + * separately, by id). See {@link TopologyAssetRestService}. + */ +public class TopologyAssetDTO { + + private String id; + private String name; + private String kind; + private String mimeType; + private long sizeBytes; + private String owner; + private Date created; + private Date lastModified; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getKind() { + return kind; + } + + public void setKind(String kind) { + this.kind = kind; + } + + public String getMimeType() { + return mimeType; + } + + public void setMimeType(String mimeType) { + this.mimeType = mimeType; + } + + public long getSizeBytes() { + return sizeBytes; + } + + public void setSizeBytes(long sizeBytes) { + this.sizeBytes = sizeBytes; + } + + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public Date getCreated() { + return created; + } + + public void setCreated(Date created) { + this.created = created; + } + + public Date getLastModified() { + return lastModified; + } + + public void setLastModified(Date lastModified) { + this.lastModified = lastModified; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java new file mode 100644 index 000000000000..c0cd31c11239 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/TopologyAssetRestService.java @@ -0,0 +1,215 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.CacheControl; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.EntityTag; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Request; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; + +import org.opennms.netmgt.topology.assets.TopologyAsset; +import org.opennms.netmgt.topology.assets.TopologyAssetDao; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import com.google.common.collect.ImmutableMap; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Upload, serve, list, and delete the images that custom topology views + * reference: view backgrounds (floor plans, rack diagrams) and custom node + * icons. Metadata travels as JSON; the bytes are posted/served raw under the + * image's own content type — no multipart. + * + *

Reads are open to any authenticated user; writes (POST/DELETE) require + * {@code ROLE_REST} or {@code ROLE_ADMIN} — enforced by the default + * {@code /api/v2/**} rules in the Spring Security configuration, so the + * resource carries no role annotations (same model as the topology views). + * + *

Only raster image types are accepted. SVG is deliberately excluded for + * now: it can carry active content, and these assets are served from the + * application origin; revisit with a sandboxed serving path if needed. Each + * {@code kind} has its own size cap — icons are reused per node and should be + * small, backgrounds are floor-plan-sized. + */ +@Component +@Path("topology/assets") +@Tag(name = "TopologyAssets", description = "Topology view image assets API") +public class TopologyAssetRestService { + + private static final Map MAX_SIZE_BY_KIND = ImmutableMap.of( + TopologyAsset.KIND_BACKGROUND, 10L * 1024 * 1024, + TopologyAsset.KIND_ICON, 512L * 1024); + + static final String[] ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}; + + @Autowired(required = false) + private TopologyAssetDao m_dao; + + @GET + @Produces(MediaType.APPLICATION_JSON) + public List list(@QueryParam("kind") final String kind) { + return getDao().findAll().stream() + .filter(asset -> kind == null || kind.equals(asset.getKind())) + .map(TopologyAssetRestService::toDto) + .collect(Collectors.toList()); + } + + @POST + @Consumes({"image/png", "image/jpeg", "image/gif", "image/webp"}) + @Produces(MediaType.APPLICATION_JSON) + public Response upload(@Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + @Context final javax.ws.rs.core.HttpHeaders headers, + @QueryParam("name") final String name, + @QueryParam("kind") final String kind, + final byte[] bytes) { + if (name == null || name.trim().isEmpty()) { + throw webException(Response.Status.BAD_REQUEST, "An asset name is required (?name=)"); + } + final Long maxSize = kind == null ? null : MAX_SIZE_BY_KIND.get(kind); + if (maxSize == null) { + throw webException(Response.Status.BAD_REQUEST, + "An asset kind is required (?kind=): one of " + MAX_SIZE_BY_KIND.keySet()); + } + if (bytes == null || bytes.length == 0) { + throw webException(Response.Status.BAD_REQUEST, "The request body must be the image bytes"); + } + if (bytes.length > maxSize) { + throw webException(Response.Status.REQUEST_ENTITY_TOO_LARGE, + "A " + kind + " asset may be at most " + maxSize + " bytes"); + } + // @Consumes already gates the type; record it without any parameters. + // A request with no Content-Type at all can still reach here, though -- + // answer 415 rather than NPE into a 500. + final MediaType contentType = headers.getMediaType(); + if (contentType == null) { + throw webException(Response.Status.UNSUPPORTED_MEDIA_TYPE, + "A Content-Type header with the image type is required"); + } + final String mimeType = contentType.getType() + "/" + contentType.getSubtype(); + + final TopologyAsset asset = new TopologyAsset(); + asset.setName(name.trim()); + asset.setKind(kind); + asset.setMimeType(mimeType); + asset.setOwner(securityContext.getUserPrincipal() == null ? null : securityContext.getUserPrincipal().getName()); + + final TopologyAsset saved = getDao().save(asset, bytes); + return Response.created(uriInfo.getAbsolutePathBuilder().path(saved.getId()).build()) + .entity(toDto(saved)) + .build(); + } + + /** + * The image bytes, under the asset's own content type. ETag'd by the + * asset's last-modified time so browsers can revalidate cheaply — icons + * especially are reused by many nodes in a view. + */ + @GET + @Path("{id}") + public Response getBytes(@Context final Request request, @PathParam("id") final String id) { + final TopologyAsset asset = require(id); + final EntityTag etag = new EntityTag(String.valueOf(asset.getLastModified() == null ? 0L : asset.getLastModified().getTime())); + + final CacheControl cacheControl = new CacheControl(); + cacheControl.setMaxAge(3600); + + final Response.ResponseBuilder notModified = request.evaluatePreconditions(etag); + if (notModified != null) { + return notModified.cacheControl(cacheControl).tag(etag).build(); + } + + final Optional bytes = getDao().getBytes(id); + if (!bytes.isPresent()) { + throw webException(Response.Status.NOT_FOUND, "No bytes stored for asset '" + id + "'"); + } + return Response.ok(bytes.get(), asset.getMimeType()) + .cacheControl(cacheControl) + .tag(etag) + .build(); + } + + @GET + @Path("{id}/meta") + @Produces(MediaType.APPLICATION_JSON) + public TopologyAssetDTO getMeta(@PathParam("id") final String id) { + return toDto(require(id)); + } + + @DELETE + @Path("{id}") + public Response delete(@PathParam("id") final String id) { + if (!getDao().delete(id)) { + throw webException(Response.Status.NOT_FOUND, "No asset with id '" + id + "'"); + } + return Response.noContent().build(); + } + + private TopologyAsset require(final String id) { + return getDao().get(id) + .orElseThrow(() -> webException(Response.Status.NOT_FOUND, "No asset with id '" + id + "'")); + } + + private TopologyAssetDao getDao() { + if (m_dao == null) { + throw webException(Response.Status.SERVICE_UNAVAILABLE, "The topology asset store is not available"); + } + return m_dao; + } + + private static TopologyAssetDTO toDto(final TopologyAsset asset) { + final TopologyAssetDTO dto = new TopologyAssetDTO(); + dto.setId(asset.getId()); + dto.setName(asset.getName()); + dto.setKind(asset.getKind()); + dto.setMimeType(asset.getMimeType()); + dto.setSizeBytes(asset.getSizeBytes()); + dto.setOwner(asset.getOwner()); + dto.setCreated(asset.getCreated()); + dto.setLastModified(asset.getLastModified()); + return dto; + } + + private static WebApplicationException webException(final Response.Status status, final String message) { + return new WebApplicationException(Response.status(status).type(MediaType.TEXT_PLAIN).entity(message).build()); + } +} diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java new file mode 100644 index 000000000000..1aa0923fc40c --- /dev/null +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/TopologyAssetRestServiceIT.java @@ -0,0 +1,216 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.codehaus.jackson.JsonNode; +import org.codehaus.jackson.map.ObjectMapper; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.test.MockLogAppender; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.core.test.rest.AbstractSpringJerseyRestTestCase; +import org.opennms.test.JUnitConfigurationEnvironment; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.web.WebAppConfiguration; + +/** + * Drives {@code /api/v2/topology/assets} end to end through the CXF test + * harness: raw-body image upload, metadata listing/filtering, byte serving + * with ETag revalidation, deletion, and the validation/error statuses + * (400 missing name/kind, 413 over the per-kind cap, 415 non-image type, + * 404 unknown id). + */ +@RunWith(OpenNMSJUnit4ClassRunner.class) +@WebAppConfiguration +@ContextConfiguration(locations = { + "classpath:/META-INF/opennms/applicationContext-soa.xml", + "classpath:/META-INF/opennms/applicationContext-commonConfigs.xml", + "classpath:/META-INF/opennms/applicationContext-minimal-conf.xml", + "classpath:/META-INF/opennms/applicationContext-dao.xml", + "classpath:/META-INF/opennms/applicationContext-mockConfigManager.xml", + "classpath*:/META-INF/opennms/component-service.xml", + "classpath*:/META-INF/opennms/component-dao.xml", + "classpath:/META-INF/opennms/applicationContext-databasePopulator.xml", + "classpath:/META-INF/opennms/mockEventIpcManager.xml", + "file:src/main/webapp/WEB-INF/applicationContext-svclayer.xml", + "file:src/main/webapp/WEB-INF/applicationContext-cxf-common.xml", + "classpath:/META-INF/opennms/applicationContext-postgresJsonStore.xml", + "classpath:/applicationContext-rest-test.xml" +}) +@JUnitConfigurationEnvironment(systemProperties = "org.opennms.timeseries.strategy=integration") +@JUnitTemporaryDatabase +public class TopologyAssetRestServiceIT extends AbstractSpringJerseyRestTestCase { + + private static final String BASE = "/topology/assets"; + + private static final byte[] IMAGE = "png-bytes-stand-in".getBytes(StandardCharsets.UTF_8); + + private final ObjectMapper m_mapper = new ObjectMapper(); + + public TopologyAssetRestServiceIT() { + super(CXF_REST_V2_CONTEXT_PATH); + } + + @Override + protected void afterServletStart() throws Exception { + MockLogAppender.setupLogging(true, "WARN"); + setUser("admin", new String[] { "ROLE_ADMIN" }); + } + + /** + * POSTs raw image bytes. The harness's sendData() can't carry query + * parameters, so this sets the query string and body itself. + */ + private MockHttpServletResponse postAsset(final String query, final String contentType, final byte[] body, + final int expectedStatus) throws Exception { + final MockHttpServletRequest request = createRequest(POST, BASE); + request.setQueryString(query); + for (final String pair : query.split("&")) { + final int eq = pair.indexOf('='); + if (eq > 0) { + // The query string carries encoded values; parameters carry decoded + // ones (as a real servlet container would present them). + request.addParameter(pair.substring(0, eq), + java.net.URLDecoder.decode(pair.substring(eq + 1), java.nio.charset.StandardCharsets.UTF_8)); + } + } + request.setContentType(contentType); + request.setContent(body); + final MockHttpServletResponse response = createResponse(); + dispatch(request, response); + assertEquals(response.getErrorMessage(), expectedStatus, response.getStatus()); + return response; + } + + private MockHttpServletResponse getRaw(final String url, final String ifNoneMatch, final int expectedStatus) throws Exception { + final MockHttpServletRequest request = createRequest(GET, url); + if (ifNoneMatch != null) { + request.addHeader("If-None-Match", ifNoneMatch); + } + final MockHttpServletResponse response = createResponse(); + dispatch(request, response); + assertEquals(response.getErrorMessage(), expectedStatus, response.getStatus()); + return response; + } + + private static String lastPathSegment(final String location) { + return location == null ? null : location.substring(location.lastIndexOf('/') + 1); + } + + @Test + @JUnitTemporaryDatabase + public void uploadServeAndDeleteLifecycle() throws Exception { + // Empty catalog to start. + assertEquals(0, m_mapper.readTree(sendRequest(GET, BASE, 200)).size()); + + // Upload a background. + final MockHttpServletResponse post = postAsset("name=DC+floor+plan&kind=background", "image/png", IMAGE, 201); + final String id = lastPathSegment(post.getHeader("Location")); + assertNotNull("POST should return a Location with an id", id); + final JsonNode created = m_mapper.readTree(post.getContentAsString()); + assertEquals("DC floor plan", created.get("name").asText()); + assertEquals("background", created.get("kind").asText()); + assertEquals("image/png", created.get("mimeType").asText()); + assertEquals(IMAGE.length, created.get("sizeBytes").asInt()); + assertEquals("admin", created.get("owner").asText()); + + // Serve the bytes back: body, content type, cacheability. + final MockHttpServletResponse bytes = getRaw(BASE + "/" + id, null, 200); + assertArrayEquals(IMAGE, bytes.getContentAsByteArray()); + assertTrue(bytes.getContentType().startsWith("image/png")); + final String etag = bytes.getHeader("ETag"); + assertNotNull("byte responses must carry an ETag", etag); + assertNotNull(bytes.getHeader("Cache-Control")); + + // Conditional GET with the ETag revalidates as a 304 without a body. + final MockHttpServletResponse notModified = getRaw(BASE + "/" + id, etag, 304); + assertEquals(0, notModified.getContentAsByteArray().length); + + // Metadata endpoint and listing. + final JsonNode meta = m_mapper.readTree(sendRequest(GET, BASE + "/" + id + "/meta", 200)); + assertEquals(id, meta.get("id").asText()); + final JsonNode all = m_mapper.readTree(sendRequest(GET, BASE, 200)); + assertEquals(1, all.size()); + + // Delete removes metadata and bytes; a second delete is a 404. + sendRequest(DELETE, BASE + "/" + id, 204); + sendRequest(GET, BASE + "/" + id, 404); + sendRequest(DELETE, BASE + "/" + id, 404); + } + + @Test + @JUnitTemporaryDatabase + public void listsCanFilterByKind() throws Exception { + postAsset("name=floor&kind=background", "image/png", IMAGE, 201); + postAsset("name=glyph&kind=icon", "image/webp", IMAGE, 201); + + assertEquals(2, m_mapper.readTree(sendRequest(GET, BASE, 200)).size()); + + final MockHttpServletRequest request = createRequest(GET, BASE); + request.setQueryString("kind=icon"); + request.addParameter("kind", "icon"); + final MockHttpServletResponse response = createResponse(); + dispatch(request, response); + assertEquals(200, response.getStatus()); + final JsonNode icons = m_mapper.readTree(response.getContentAsString()); + assertEquals(1, icons.size()); + assertEquals("glyph", icons.get(0).get("name").asText()); + } + + @Test + @JUnitTemporaryDatabase + public void rejectsBadUploads() throws Exception { + // Missing name / missing or unknown kind. + postAsset("kind=background", "image/png", IMAGE, 400); + postAsset("name=x", "image/png", IMAGE, 400); + postAsset("name=x&kind=wallpaper", "image/png", IMAGE, 400); + + // Non-image content types are rejected by @Consumes. + postAsset("name=x&kind=icon", "text/plain", IMAGE, 415); + postAsset("name=x&kind=icon", "image/svg+xml", IMAGE, 415); + + // Per-kind size caps: this payload is over the icon cap (512 KiB) + // but under the background cap (10 MiB). + final byte[] big = new byte[600 * 1024]; + Arrays.fill(big, (byte) 'a'); + postAsset("name=big&kind=icon", "image/png", big, 413); + postAsset("name=big&kind=background", "image/png", big, 201); + + // Empty body. + postAsset("name=x&kind=icon", "image/png", new byte[0], 400); + + // Unknown asset id. + sendRequest(GET, BASE + "/no-such-id", 404); + sendRequest(GET, BASE + "/no-such-id/meta", 404); + } +}