Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Folderwatcher] [WIP] Azure blob storage containers monitoring support #14926

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

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;
Expand All @@ -42,7 +43,7 @@
public class FolderWatcherHandlerFactory extends BaseThingHandlerFactory {

private static final Set<ThingTypeUID> 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
Expand All @@ -65,6 +66,8 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> listContainer(String prefix) throws Exception {
Map<String, String> headers = new HashMap<String, String>();
Map<String, String> params = new HashMap<String, String>();
return listBlob(prefix, headers, params);
}

public List<String> listBlob(String prefix, Map<String, String> headers, Map<String, String> 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 <xml. Need to find out why
String sResponse = contentResponse.getContentAsString();
InputSource is = new InputSource(new StringReader(sResponse.substring(sResponse.indexOf("<"))));
Copy link
Contributor Author

@goopilot goopilot May 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm doing this, but I don't like it.
The reason why I'm doing this that Azure in the HTTP response has some additional character before <?xml statement. So the DOM parser fails.

Wireshark also shows it:
2023-05-02 16 24 29

Did anybody experience that and if yes, what is the best solution?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wborn did you experience similar issue with Azure?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, maybe it's a BOM?

https://en.m.wikipedia.org/wiki/Byte_order_mark

Document doc = docBuilder.parse(is);
NodeList nameNodesList = doc.getElementsByTagName("Blob");
List<String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -41,15 +29,15 @@
* @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;
Expand All @@ -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;
Expand All @@ -69,124 +60,15 @@ public AWS4SignerBase(URL endpointUrl, String httpMethod, String serviceName, St
dateStampFormat.setTimeZone(new SimpleTimeZone(0, "UTC"));
}

protected static String getCanonicalizeHeaderNames(Map<String, String> headers) {
List<String> 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<String, String> headers) {
if (headers == null || headers.isEmpty()) {
return "";
}

List<String> 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<String, String> parameters) throws HttpUtilException {
if (parameters == null || parameters.isEmpty()) {
return "";
}

SortedMap<String, String> sorted = new TreeMap<>();
Iterator<Map.Entry<String, String>> pairs = parameters.entrySet().iterator();

while (pairs.hasNext()) {
Map.Entry<String, String> 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<String, String> 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);
}
}
}
Loading