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