From 1a84167b37174935ed61bec9b76c1e5ff4edc991 Mon Sep 17 00:00:00 2001 From: Alexandr Salamatov Date: Tue, 2 May 2023 16:11:14 -0500 Subject: [PATCH] Add Azure Thing Signed-off-by: Alexandr Salamatov --- .../FolderWatcherBindingConstants.java | 1 + .../internal/FolderWatcherHandlerFactory.java | 5 +- .../internal/api/AzureActions.java | 147 ++++++++++++++++ .../internal/api/auth/AWS4SignerBase.java | 132 +------------- .../Azure4SignerForAuthorizationHeader.java | 63 +++++++ .../internal/api/auth/AzureSignerBase.java | 81 +++++++++ .../internal/api/auth/SignerBase.java | 162 ++++++++++++++++++ .../config/AzureBlobWatcherConfiguration.java | 30 ++++ .../handler/AzureBlobWatcherHandler.java | 133 ++++++++++++++ .../OH-INF/i18n/folderwatcher.properties | 12 ++ .../resources/OH-INF/thing/thing-types.xml | 54 +++++- 11 files changed, 686 insertions(+), 134 deletions(-) create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/AzureActions.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/Azure4SignerForAuthorizationHeader.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AzureSignerBase.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/SignerBase.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/config/AzureBlobWatcherConfiguration.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/AzureBlobWatcherHandler.java diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/FolderWatcherBindingConstants.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/FolderWatcherBindingConstants.java index 4be0baf3fc9..a2bb947aa98 100644 --- a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/FolderWatcherBindingConstants.java +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/FolderWatcherBindingConstants.java @@ -27,5 +27,6 @@ public class FolderWatcherBindingConstants { public static final ThingTypeUID THING_TYPE_FTPFOLDER = new ThingTypeUID(BINDING_ID, "ftpfolder"); public static final ThingTypeUID THING_TYPE_LOCALFOLDER = new ThingTypeUID(BINDING_ID, "localfolder"); public static final ThingTypeUID THING_TYPE_S3BUCKET = new ThingTypeUID(BINDING_ID, "s3bucket"); + public static final ThingTypeUID THING_TYPE_AZUREBLOB = new ThingTypeUID(BINDING_ID, "azureblob"); public static final String CHANNEL_NEWFILE = "newfile"; } diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/FolderWatcherHandlerFactory.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/FolderWatcherHandlerFactory.java index 8352acac04f..48cfe16e846 100644 --- a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/FolderWatcherHandlerFactory.java +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/FolderWatcherHandlerFactory.java @@ -18,6 +18,7 @@ import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.folderwatcher.internal.handler.AzureBlobWatcherHandler; import org.openhab.binding.folderwatcher.internal.handler.FtpFolderWatcherHandler; import org.openhab.binding.folderwatcher.internal.handler.LocalFolderWatcherHandler; import org.openhab.binding.folderwatcher.internal.handler.S3BucketWatcherHandler; @@ -42,7 +43,7 @@ import org.osgi.service.component.annotations.Reference; public class FolderWatcherHandlerFactory extends BaseThingHandlerFactory { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_FTPFOLDER, - THING_TYPE_LOCALFOLDER, THING_TYPE_S3BUCKET); + THING_TYPE_LOCALFOLDER, THING_TYPE_S3BUCKET, THING_TYPE_AZUREBLOB); private HttpClientFactory httpClientFactory; @Activate @@ -65,6 +66,8 @@ public class FolderWatcherHandlerFactory extends BaseThingHandlerFactory { return new LocalFolderWatcherHandler(thing); } else if (THING_TYPE_S3BUCKET.equals(thingTypeUID)) { return new S3BucketWatcherHandler(thing, httpClientFactory); + } else if (THING_TYPE_AZUREBLOB.equals(thingTypeUID)) { + return new AzureBlobWatcherHandler(thing, httpClientFactory); } return null; } diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/AzureActions.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/AzureActions.java new file mode 100644 index 00000000000..761a1625035 --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/AzureActions.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.folderwatcher.internal.api; + +import static org.eclipse.jetty.http.HttpHeader.*; +import static org.eclipse.jetty.http.HttpMethod.*; + +import java.io.StringReader; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.openhab.binding.folderwatcher.internal.api.auth.Azure4SignerForAuthorizationHeader; +import org.openhab.binding.folderwatcher.internal.api.exception.AuthException; +import org.openhab.binding.folderwatcher.internal.api.util.HttpUtilException; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +/** + * The {@link AzureActions} class contains AWS S3 API implementation. + * + * @author Alexandr Salamatov - Initial contribution + */ +@NonNullByDefault +public class AzureActions { + private final HttpClient httpClient; + private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1); + private static final String CONTENT_TYPE = "application/xml"; + private URL containerUri; + private String azureAccessKey; + private String accountName; + private String containerName; + + public AzureActions(HttpClientFactory httpClientFactory, String accountName, String containerName) { + this(httpClientFactory, accountName, containerName, ""); + } + + public AzureActions(HttpClientFactory httpClientFactory, String accountName, String containerName, + String azureAccessKey) { + this.httpClient = httpClientFactory.getCommonHttpClient(); + try { + this.containerUri = new URL("https://" + accountName + ".blob.core.windows.net/" + containerName); + } catch (MalformedURLException e) { + throw new RuntimeException("Unable to parse service endpoint: " + e.getMessage()); + } + this.azureAccessKey = azureAccessKey; + this.accountName = accountName; + this.containerName = containerName; + } + + public List listContainer(String prefix) throws Exception { + Map headers = new HashMap(); + Map params = new HashMap(); + return listBlob(prefix, headers, params); + } + + public List listBlob(String prefix, Map headers, Map params) + throws Exception { + + params.put("restype", "container"); + params.put("comp", "list"); + params.put("maxresults", "1000"); + params.put("prefix", prefix); + headers.put(ACCEPT.toString(), CONTENT_TYPE); + + if (!azureAccessKey.isEmpty()) { + Azure4SignerForAuthorizationHeader signer = new Azure4SignerForAuthorizationHeader("GET", + this.containerUri); + String authorization; + try { + authorization = signer.computeSignature(headers, params, accountName, azureAccessKey, containerName); + } catch (HttpUtilException e) { + throw new AuthException(e); + } + headers.put("Authorization", authorization); + } + + Request request = httpClient.newRequest(this.containerUri.toString()) // + .method(GET) // + .timeout(REQUEST_TIMEOUT.toNanos(), TimeUnit.NANOSECONDS); // + + for (String headerKey : headers.keySet()) { + request.header(headerKey, headers.get(headerKey)); + } + for (String paramKey : params.keySet()) { + request.param(paramKey, params.get(paramKey)); + } + + ContentResponse contentResponse = request.send(); + if (contentResponse.getStatus() != 200) { + throw new Exception("HTTP Response is not 200"); + } + + DocumentBuilderFactory docBuilderFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder docBuilder = docBuilderFactory.newDocumentBuilder(); + // This returns extra character before returnList = new ArrayList<>(); + + if (nameNodesList.getLength() == 0) { + return returnList; + } + + for (int i = 0; i < nameNodesList.getLength(); i++) { + returnList.add(nameNodesList.item(i).getFirstChild().getTextContent()); + } + + nameNodesList = doc.getElementsByTagName("NextMarker"); + if (nameNodesList.getLength() > 0) { + if (nameNodesList.item(0).getChildNodes().getLength() > 0) { + String continueToken = nameNodesList.item(0).getFirstChild().getTextContent(); + params.clear(); + headers.clear(); + params.put("marker", continueToken); + returnList.addAll(listBlob(prefix, headers, params)); + } + } + return returnList; + } +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AWS4SignerBase.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AWS4SignerBase.java index d2db857ca8d..82f24011cfb 100644 --- a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AWS4SignerBase.java +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AWS4SignerBase.java @@ -13,25 +13,13 @@ package org.openhab.binding.folderwatcher.internal.api.auth; import java.net.URL; -import java.security.MessageDigest; import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Map; import java.util.SimpleTimeZone; -import java.util.SortedMap; -import java.util.TreeMap; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; import org.eclipse.jdt.annotation.NonNullByDefault; import org.openhab.binding.folderwatcher.internal.api.exception.AuthException; import org.openhab.binding.folderwatcher.internal.api.util.BinaryUtils; import org.openhab.binding.folderwatcher.internal.api.util.HttpUtilException; -import org.openhab.binding.folderwatcher.internal.api.util.HttpUtils; /** * The {@link AWS4SignerBase} class contains based methods for AWS S3 API authentication. @@ -41,15 +29,15 @@ import org.openhab.binding.folderwatcher.internal.api.util.HttpUtils; * @author Alexandr Salamatov - Initial contribution */ @NonNullByDefault -public abstract class AWS4SignerBase { +public abstract class AWS4SignerBase extends SignerBase { - public static final String EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; - public static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; public static final String SCHEME = "AWS4"; - public static final String ALGORITHM = "HMAC-SHA256"; + // public static final String ALGORITHM = "HMAC-SHA256"; public static final String TERMINATOR = "aws4_request"; public static final String ISO8601_BASIC_FORMAT = "yyyyMMdd'T'HHmmss'Z'"; public static final String DATESTRING_FORMAT = "yyyyMMdd"; + protected static String PAIR_SEPARATOR = "&"; + protected static String VALEU_SEPARATOR = "="; protected URL endpointUrl; protected String httpMethod; protected String serviceName; @@ -58,6 +46,9 @@ public abstract class AWS4SignerBase { protected final SimpleDateFormat dateStampFormat; public AWS4SignerBase(URL endpointUrl, String httpMethod, String serviceName, String regionName) { + + super(PAIR_SEPARATOR, VALEU_SEPARATOR); + this.endpointUrl = endpointUrl; this.httpMethod = httpMethod; this.serviceName = serviceName; @@ -69,124 +60,15 @@ public abstract class AWS4SignerBase { dateStampFormat.setTimeZone(new SimpleTimeZone(0, "UTC")); } - protected static String getCanonicalizeHeaderNames(Map headers) { - List sortedHeaders = new ArrayList<>(); - sortedHeaders.addAll(headers.keySet()); - Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER); - - StringBuilder buffer = new StringBuilder(); - for (String header : sortedHeaders) { - if (buffer.length() > 0) { - buffer.append(";"); - } - buffer.append(header.toLowerCase()); - } - return buffer.toString(); - } - - protected static String getCanonicalizedHeaderString(Map headers) { - if (headers == null || headers.isEmpty()) { - return ""; - } - - List sortedHeaders = new ArrayList<>(); - sortedHeaders.addAll(headers.keySet()); - Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER); - - StringBuilder buffer = new StringBuilder(); - for (String key : sortedHeaders) { - buffer.append(key.toLowerCase().replaceAll("\\s+", " ") + ":" + headers.get(key).replaceAll("\\s+", " ")); - buffer.append("\n"); - } - return buffer.toString(); - } - protected static String getCanonicalRequest(URL endpoint, String httpMethod, String queryParameters, String canonicalizedHeaderNames, String canonicalizedHeaders, String bodyHash) throws HttpUtilException { return httpMethod + "\n" + getCanonicalizedResourcePath(endpoint) + "\n" + queryParameters + "\n" + canonicalizedHeaders + "\n" + canonicalizedHeaderNames + "\n" + bodyHash; } - protected static String getCanonicalizedResourcePath(URL endpoint) throws HttpUtilException { - if (endpoint == null) { - return "/"; - } - String path = endpoint.getPath(); - if (path == null || path.isEmpty()) { - return "/"; - } - - String encodedPath = HttpUtils.urlEncode(path, true); - if (encodedPath.startsWith("/")) { - return encodedPath; - } else { - return "/".concat(encodedPath); - } - } - - public static String getCanonicalizedQueryString(Map parameters) throws HttpUtilException { - if (parameters == null || parameters.isEmpty()) { - return ""; - } - - SortedMap sorted = new TreeMap<>(); - Iterator> pairs = parameters.entrySet().iterator(); - - while (pairs.hasNext()) { - Map.Entry pair = pairs.next(); - String key = pair.getKey(); - String value = pair.getValue(); - sorted.put(HttpUtils.urlEncode(key, false), HttpUtils.urlEncode(value, false)); - } - - StringBuilder builder = new StringBuilder(); - pairs = sorted.entrySet().iterator(); - while (pairs.hasNext()) { - Map.Entry pair = pairs.next(); - builder.append(pair.getKey()); - builder.append("="); - builder.append(pair.getValue()); - if (pairs.hasNext()) { - builder.append("&"); - } - } - return builder.toString(); - } - protected static String getStringToSign(String scheme, String algorithm, String dateTime, String scope, String canonicalRequest) throws AuthException { return scheme + "-" + algorithm + "\n" + dateTime + "\n" + scope + "\n" + BinaryUtils.toHex(hash(canonicalRequest)); } - - public static byte[] hash(String text) throws AuthException { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(text.getBytes("UTF-8")); - return md.digest(); - } catch (Exception e) { - throw new AuthException("Unable to compute hash while signing request: " + e.getMessage(), e); - } - } - - public static byte[] hash(byte[] data) throws AuthException { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(data); - return md.digest(); - } catch (Exception e) { - throw new AuthException("Unable to compute hash while signing request: " + e.getMessage(), e); - } - } - - protected static byte[] sign(String stringData, byte[] key, String algorithm) throws AuthException { - try { - byte[] data = stringData.getBytes("UTF-8"); - Mac mac = Mac.getInstance(algorithm); - mac.init(new SecretKeySpec(key, algorithm)); - return mac.doFinal(data); - } catch (Exception e) { - throw new AuthException("Unable to calculate a request signature: " + e.getMessage(), e); - } - } } diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/Azure4SignerForAuthorizationHeader.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/Azure4SignerForAuthorizationHeader.java new file mode 100644 index 00000000000..9f2ea892010 --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/Azure4SignerForAuthorizationHeader.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.folderwatcher.internal.api.auth; + +import java.net.URL; +import java.util.Base64; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.folderwatcher.internal.api.exception.AuthException; +import org.openhab.binding.folderwatcher.internal.api.util.HttpUtilException; + +/** + * The {@link Azure4SignerForAuthorizationHeader} class contains methods for Azure Blob API authentication using HTTPS + * headers. + * + * @author Alexandr Salamatov - Initial contribution + */ +@NonNullByDefault +public class Azure4SignerForAuthorizationHeader extends AzureSignerBase { + + public Azure4SignerForAuthorizationHeader(String httpMethod, URL endpointUrl) { + super(endpointUrl, httpMethod, "serviceName", "regionName"); + } + + public String computeSignature(Map headers, Map queryParameters, + String AzureAccount, String AzureSecretKey, String azureContainerName) + throws AuthException, HttpUtilException { + String dateTimeStamp = dateTimeFormat.format(java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC)); + headers.put("x-ms-date", dateTimeStamp); + headers.put("x-ms-version", "2020-08-04"); + + Map filteredHeaders = new HashMap<>(); + filteredHeaders.putAll(headers); + Iterator> iterator = filteredHeaders.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (!entry.getKey().startsWith(HEADER_FILTER)) { + iterator.remove(); + } + } + String canonicalizedHeaders = getCanonicalizedHeaderString(filteredHeaders); + String canonicalizedQueryParameters = getCanonicalizedQueryString(queryParameters); + String canonicalilezdResource = getCanonicalResource(endpointUrl, canonicalizedQueryParameters); + String stringToSign = getStringToSign("GET", "", "", "", "", "", "", "", "", "", "", "", canonicalizedHeaders, + canonicalilezdResource); + byte[] kSecret = Base64.getDecoder().decode(AzureSecretKey); + byte[] signed = sign(stringToSign, kSecret, "HmacSHA256"); + return "SharedKey" + " " + AzureAccount + ":" + Base64.getEncoder().encodeToString(signed); + } +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AzureSignerBase.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AzureSignerBase.java new file mode 100644 index 00000000000..f0193aa9412 --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AzureSignerBase.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.folderwatcher.internal.api.auth; + +import java.net.URL; +import java.time.format.DateTimeFormatter; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.folderwatcher.internal.api.exception.AuthException; +import org.openhab.binding.folderwatcher.internal.api.util.HttpUtilException; +import org.openhab.binding.folderwatcher.internal.api.util.HttpUtils; + +/** + * The {@link AzureSignerBase} class contains based methods for Azure Blob API authentication. + * + * @author Alexandr Salamatov - Initial contribution + */ +@NonNullByDefault +public abstract class AzureSignerBase extends SignerBase { + protected static String PAIR_SEPARATOR = "\n"; + protected static String VALEU_SEPARATOR = ":"; + protected static String HEADER_FILTER = "x-ms-"; + protected URL endpointUrl; + protected String httpMethod; + protected String serviceName; + protected String regionName; + protected DateTimeFormatter dateTimeFormat; + + public AzureSignerBase(URL endpointUrl, String httpMethod, String serviceName, String regionName) { + + super(PAIR_SEPARATOR, VALEU_SEPARATOR); + + this.endpointUrl = endpointUrl; + this.httpMethod = httpMethod; + this.serviceName = serviceName; + this.regionName = regionName; + + dateTimeFormat = java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME; + } + + protected static String getCanonicalResource(URL endpoint, String queryParameters) throws HttpUtilException { + return getCanonicalizedResourceName(endpoint) + getCanonicalizedResourcePath(endpoint) + "\n" + queryParameters; + } + + protected static String getCanonicalizedResourceName(URL endpoint) throws HttpUtilException { + if (endpoint == null) { + return "/"; + } + String path = endpoint.getHost().split("\\.")[0]; + if (path == null || path.isEmpty()) { + return "/"; + } + + String encodedPath = HttpUtils.urlEncode(path, true); + if (encodedPath.startsWith("/")) { + return encodedPath; + } else { + return "/".concat(encodedPath); + } + } + + protected static String getStringToSign(String VERB, String ContentEncoding, String ContentLanguage, + String ContentLength, String ContentMD5, String ContentType, String Date, String IfModifiedSince, + String IfMatch, String IfNoneMatch, String IfUnmodifiedSince, String Range, String CanonicalizedHeaders, + String CanonicalizedResource) throws AuthException { + + return VERB + "\n" + ContentEncoding + "\n" + ContentLanguage + "\n" + ContentLength + "\n" + ContentMD5 + "\n" + + ContentType + "\n" + Date + "\n" + IfModifiedSince + "\n" + IfMatch + "\n" + IfNoneMatch + "\n" + + IfUnmodifiedSince + "\n" + Range + "\n" + CanonicalizedHeaders + CanonicalizedResource; + } +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/SignerBase.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/SignerBase.java new file mode 100644 index 00000000000..4ec656050ab --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/SignerBase.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.folderwatcher.internal.api.auth; + +import java.net.URL; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.folderwatcher.internal.api.exception.AuthException; +import org.openhab.binding.folderwatcher.internal.api.util.HttpUtilException; +import org.openhab.binding.folderwatcher.internal.api.util.HttpUtils; + +/** + * The {@link SignerBase} class contains based methods for API authentication. + *

+ * Based on offical AWS example {@see https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-examples-using-sdks.html} + * + * @author Alexandr Salamatov - Initial contribution + */ +@NonNullByDefault +public abstract class SignerBase { + + public static final String EMPTY_BODY_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + public static final String UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"; + public static final String ALGORITHM = "HMAC-SHA256"; + private static String PAIR_SEPARATOR = "&"; + private static String VALUE_SEPARATOR = "="; + + public SignerBase(String PAIRSEPARATOR, String VALUESEPARATOR) { + PAIR_SEPARATOR = PAIRSEPARATOR; + VALUE_SEPARATOR = VALUESEPARATOR; + } + + protected static String getCanonicalizeHeaderNames(Map headers) { + List sortedHeaders = new ArrayList<>(); + sortedHeaders.addAll(headers.keySet()); + Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER); + + StringBuilder buffer = new StringBuilder(); + for (String header : sortedHeaders) { + if (buffer.length() > 0) { + buffer.append(";"); + } + buffer.append(header.toLowerCase()); + } + return buffer.toString(); + } + + protected static String getCanonicalizedHeaderString(Map headers) { + if (headers == null || headers.isEmpty()) { + return ""; + } + + List sortedHeaders = new ArrayList<>(); + sortedHeaders.addAll(headers.keySet()); + Collections.sort(sortedHeaders, String.CASE_INSENSITIVE_ORDER); + + StringBuilder buffer = new StringBuilder(); + for (String key : sortedHeaders) { + buffer.append(key.toLowerCase().replaceAll("\\s+", " ") + ":" + headers.get(key).replaceAll("\\s+", " ")); + buffer.append("\n"); + } + return buffer.toString(); + } + + protected static String getCanonicalizedResourcePath(URL endpoint) throws HttpUtilException { + if (endpoint == null) { + return "/"; + } + String path = endpoint.getPath(); + if (path == null || path.isEmpty()) { + return "/"; + } + + String encodedPath = HttpUtils.urlEncode(path, true); + if (encodedPath.startsWith("/")) { + return encodedPath; + } else { + return "/".concat(encodedPath); + } + } + + public static String getCanonicalizedQueryString(Map parameters) throws HttpUtilException { + if (parameters == null || parameters.isEmpty()) { + return ""; + } + + SortedMap sorted = new TreeMap<>(); + Iterator> pairs = parameters.entrySet().iterator(); + + while (pairs.hasNext()) { + Map.Entry pair = pairs.next(); + String key = pair.getKey(); + String value = pair.getValue(); + sorted.put(HttpUtils.urlEncode(key, false), HttpUtils.urlEncode(value, false)); + } + + StringBuilder builder = new StringBuilder(); + pairs = sorted.entrySet().iterator(); + while (pairs.hasNext()) { + Map.Entry pair = pairs.next(); + builder.append(pair.getKey()); + builder.append(VALUE_SEPARATOR); + builder.append(pair.getValue()); + if (pairs.hasNext()) { + builder.append(PAIR_SEPARATOR); + } + } + return builder.toString(); + } + + public static byte[] hash(String text) throws AuthException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(text.getBytes("UTF-8")); + return md.digest(); + } catch (Exception e) { + throw new AuthException("Unable to compute hash while signing request: " + e.getMessage(), e); + } + } + + public static byte[] hash(byte[] data) throws AuthException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(data); + return md.digest(); + } catch (Exception e) { + throw new AuthException("Unable to compute hash while signing request: " + e.getMessage(), e); + } + } + + protected static byte[] sign(String stringData, byte[] key, String algorithm) throws AuthException { + try { + byte[] data = stringData.getBytes("UTF-8"); + Mac mac = Mac.getInstance(algorithm); + mac.init(new SecretKeySpec(key, algorithm)); + return mac.doFinal(data); + } catch (Exception e) { + throw new AuthException("Unable to calculate a request signature: " + e.getMessage(), e); + } + } +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/config/AzureBlobWatcherConfiguration.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/config/AzureBlobWatcherConfiguration.java new file mode 100644 index 00000000000..17f11c62f58 --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/config/AzureBlobWatcherConfiguration.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.folderwatcher.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AzureBlobWatcherConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Alexandr Salamatov - Initial contribution + */ +@NonNullByDefault +public class AzureBlobWatcherConfiguration { + public String azureAccountName = ""; + public String azureContainerName = ""; + public boolean azureAnonymous; + public String azureAccessKey = ""; + public int pollIntervalAzure; + public String contanerPath = ""; +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/AzureBlobWatcherHandler.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/AzureBlobWatcherHandler.java new file mode 100644 index 00000000000..ce70c7bebec --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/AzureBlobWatcherHandler.java @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.folderwatcher.internal.handler; + +import static org.openhab.binding.folderwatcher.internal.FolderWatcherBindingConstants.CHANNEL_NEWFILE; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.folderwatcher.internal.api.AzureActions; +import org.openhab.binding.folderwatcher.internal.common.WatcherCommon; +import org.openhab.binding.folderwatcher.internal.config.AzureBlobWatcherConfiguration; +import org.openhab.core.OpenHAB; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AzureBlobWatcherHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Alexandr Salamatov - Initial contribution + */ +@NonNullByDefault +public class AzureBlobWatcherHandler extends BaseThingHandler { + private final Logger logger = LoggerFactory.getLogger(AzureBlobWatcherHandler.class); + private AzureBlobWatcherConfiguration config = new AzureBlobWatcherConfiguration(); + private File currentBlobListingFile = new File(OpenHAB.getUserDataFolder() + File.separator + "AzureBlob" + + File.separator + thing.getUID().getAsString().replace(':', '_') + ".data"); + private @Nullable ScheduledFuture executionJob; + private List previousBlobListing = new ArrayList<>(); + private HttpClientFactory httpClientFactory; + private @Nullable AzureActions azure; + + public AzureBlobWatcherHandler(Thing thing, HttpClientFactory httpClientFactory) { + super(thing); + this.httpClientFactory = httpClientFactory; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("Channel {} triggered with command {}", channelUID.getId(), command); + if (command instanceof RefreshType) { + refreshAzureBlobInformation(); + } + } + + @Override + public void initialize() { + config = getConfigAs(AzureBlobWatcherConfiguration.class); + + if (config.azureAnonymous) { + azure = new AzureActions(httpClientFactory, config.azureAccountName, config.azureContainerName); + } else { + azure = new AzureActions(httpClientFactory, config.azureAccountName, config.azureContainerName, + config.azureAccessKey); + } + + try { + previousBlobListing = WatcherCommon.initStorage(currentBlobListingFile, + config.azureAccountName + "-" + config.azureContainerName); + } catch (IOException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + logger.debug("Can't write file {}: {}", currentBlobListingFile, e.getMessage()); + return; + } + + if (refreshAzureBlobInformation()) { + if (config.pollIntervalAzure > 0) { + executionJob = scheduler.scheduleWithFixedDelay(this::refreshAzureBlobInformation, + config.pollIntervalAzure, config.pollIntervalAzure, TimeUnit.SECONDS); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Polling interval must be greater then 0 seconds"); + return; + } + } + } + + private boolean refreshAzureBlobInformation() { + List currentBlobListing = new ArrayList<>(); + try { + currentBlobListing = azure.listContainer(config.contanerPath); + updateStatus(ThingStatus.ONLINE); + List difBlobListing = new ArrayList<>(currentBlobListing); + difBlobListing.removeAll(previousBlobListing); + difBlobListing.forEach(file -> triggerChannel(CHANNEL_NEWFILE, file)); + + if (!difBlobListing.isEmpty()) { + WatcherCommon.saveNewListing(difBlobListing, currentBlobListingFile); + } + previousBlobListing = new ArrayList<>(currentBlobListing); + } catch (Exception e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "Can't connect to the contaner: " + e.getMessage()); + logger.debug("Can't connect to the contaner: {}", e.getMessage()); + return false; + } + return true; + } + + @Override + public void dispose() { + ScheduledFuture executionJob = this.executionJob; + if (executionJob != null) { + executionJob.cancel(true); + this.executionJob = null; + } + } +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/resources/OH-INF/i18n/folderwatcher.properties b/bundles/org.openhab.binding.folderwatcher/src/main/resources/OH-INF/i18n/folderwatcher.properties index 15496e68832..f8009c931a1 100644 --- a/bundles/org.openhab.binding.folderwatcher/src/main/resources/OH-INF/i18n/folderwatcher.properties +++ b/bundles/org.openhab.binding.folderwatcher/src/main/resources/OH-INF/i18n/folderwatcher.properties @@ -61,6 +61,18 @@ thing-type.config.folderwatcher.s3bucket.s3BucketName.label = S3 Bucket Name thing-type.config.folderwatcher.s3bucket.s3BucketName.description = Name of the S3 bucket to be watched thing-type.config.folderwatcher.s3bucket.s3Path.label = S3 Path thing-type.config.folderwatcher.s3bucket.s3Path.description = S3 path (folder) to be monitored +thing-type.config.folderwatcher.azureblob.azureAccountName.label = Azure Account Name +thing-type.config.folderwatcher.azureblob.azureAccountName.description = Name of the Azure account where the conaner located +thing-type.config.folderwatcher.azureblob.azureContainerName.label = Azure Blob Container Name +thing-type.config.folderwatcher.azureblob.azureContainerName.description = Name of the Azure container to be watched +thing-type.config.folderwatcher.azureblob.contanerPath.label = Container Path +thing-type.config.folderwatcher.azureblob.contanerPath.description = Container path (folder) to be monitored +thing-type.config.folderwatcher.azureblob.pollIntervalAzure.label = Polling Interval +thing-type.config.folderwatcher.azureblob.pollIntervalAzure.description = Interval for polling Azure contaner changes, in seconds +thing-type.config.folderwatcher.azureblob.azureAnonymous.label = Anonymous Connection +thing-type.config.folderwatcher.azureblob.azureAnonymous.description = Connect anonymously (works for public containers) +thing-type.config.folderwatcher.azureblob.azureAccessKey.label = Azure Access Key +thing-type.config.folderwatcher.azureblob.azureAccessKey.description = Access Key for Azure storage account # channel types diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.folderwatcher/src/main/resources/OH-INF/thing/thing-types.xml index 825acecb916..15472e76656 100644 --- a/bundles/org.openhab.binding.folderwatcher/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.folderwatcher/src/main/resources/OH-INF/thing/thing-types.xml @@ -4,6 +4,14 @@ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0" xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd"> + + trigger + + A new file name + String + + + FTP folder to be watched @@ -82,14 +90,6 @@ - - trigger - - A new file name - String - - - Local folder to be watched @@ -169,4 +169,42 @@ + + + Azure Blob Storage to be watched + + + + + + + + + Name of the Azure account where the conaner located + + + + Name of the Azure container to be watched + + + + Container path (folder) to be monitored + + + + Interval for polling Azure contaner changes, in seconds + 60 + true + + + + false + Connect anonymously (works for public containers) + + + + Access Key for Azure storage account + + +