From 3b2c5746843dd4dc77a318ce9c613851f22ff5e7 Mon Sep 17 00:00:00 2001 From: goopilot <40123561+goopilot@users.noreply.github.com> Date: Sat, 15 Apr 2023 14:22:01 -0500 Subject: [PATCH] [Folderwatcher] AWS S3 buckets monitoring support (#14669) * Add S3 Thing Signed-off-by: Alexandr Salamatov --- .../README.md | 89 +++++--- .../FolderWatcherBindingConstants.java | 1 + .../internal/FolderWatcherHandlerFactory.java | 14 +- .../folderwatcher/internal/api/S3Actions.java | 140 +++++++++++++ .../internal/api/auth/AWS4SignerBase.java | 192 ++++++++++++++++++ .../AWS4SignerForAuthorizationHeader.java | 70 +++++++ .../internal/api/util/BinaryUtils.java | 54 +++++ .../internal/api/util/HttpUtils.java | 41 ++++ .../config/S3BucketWatcherConfiguration.java | 31 +++ .../handler/FtpFolderWatcherHandler.java | 2 + .../handler/LocalFolderWatcherHandler.java | 1 + .../handler/S3BucketWatcherHandler.java | 131 ++++++++++++ .../OH-INF/i18n/folderwatcher.properties | 22 +- .../resources/OH-INF/thing/thing-types.xml | 52 ++++- 14 files changed, 801 insertions(+), 39 deletions(-) create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/S3Actions.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AWS4SignerBase.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AWS4SignerForAuthorizationHeader.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/util/BinaryUtils.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/util/HttpUtils.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/config/S3BucketWatcherConfiguration.java create mode 100644 bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/S3BucketWatcherHandler.java diff --git a/bundles/org.openhab.binding.folderwatcher/README.md b/bundles/org.openhab.binding.folderwatcher/README.md index 6d9d9b4b0c..1fb0aad0bb 100644 --- a/bundles/org.openhab.binding.folderwatcher/README.md +++ b/bundles/org.openhab.binding.folderwatcher/README.md @@ -1,54 +1,66 @@ # FolderWatcher Binding -This binding is intended to monitor FTP and local folder and its subfolders and notify of new files +This binding is intended to monitor FTP, local folder and S3 bucket and its subfolders and notify of new files ## Supported Things -Currently the binding support two types of things: `ftpfolder` and `localfolder`. +Currently the binding support three types of things: `ftpfolder`, `localfolder` and `s3bucket`. ## Thing Configuration The `ftpfolder` thing has the following configuration options: -| Parameter | Name | Description | Required | Default value | -|-------------|--------------|------------------------------------------------------------------------------------------------------------------------|----------|---------------| -| ftpAddress | FTP server | IP address of FTP server | yes | n/a | -| ftpPort | FTP port | Port of FTP server | yes | 21 | -| secureMode | FTP Security | FTP Security | yes | None | -| ftpUsername | Username | FTP user name | yes | n/a | -| ftpPassword | Password | FTP password | yes | n/a | -| ftpDir | RootDir | Root directory to be watched | yes | n/a | -| listRecursiveFtp | List Sub Folders | Allow listing of sub folders | yes | No | -| listHidden | List Hidden | Allow listing of hidden files | yes | false | -| connectionTimeout | Connection timeout, s | Connection timeout for FTP request | yes | 30 | -| pollInterval | Polling interval, s | Interval for polling folder changes | yes | 60 | -| diffHours | Time stamp difference, h | How many hours back to analyze | yes | 24 | +| Parameter | Name | Description | Required | Default value | +|-------------------|--------------------------|-------------------------------------|----------|---------------| +| ftpAddress | FTP server | IP address of FTP server | yes | n/a | +| ftpPort | FTP port | Port of FTP server | yes | 21 | +| secureMode | FTP Security | FTP Security | yes | None | +| ftpUsername | Username | FTP user name | yes | n/a | +| ftpPassword | Password | FTP password | yes | n/a | +| ftpDir | RootDir | Root directory to be watched | yes | n/a | +| listRecursiveFtp | List Sub Folders | Allow listing of sub folders | yes | No | +| listHidden | List Hidden | Allow listing of hidden files | yes | false | +| connectionTimeout | Connection timeout, s | Connection timeout for FTP request | yes | 30 | +| pollInterval | Polling interval, s | Interval for polling folder changes | yes | 60 | +| diffHours | Time stamp difference, h | How many hours back to analyze | yes | 24 | The `localfolder` thing has the following configuration options: -| Parameter | Name | Description | Required | Default value | -|-------------|--------------|------------------------------------------------------------------------------------------------------------------------|----------|---------------| -| localDir | Local Directory | Local directory to be watched | yes | n/a | -| listHiddenLocal | List Hidden | Allow listing of hidden files | yes | No | -| pollIntervalLocal | Polling interval, s | Interval for polling folder changes | yes | 60 | -| listRecursiveLocal | List Sub Folders | Allow listing of sub folders | yes | No | - +| Parameter | Name | Description | Required | Default value | +|--------------------|---------------------|-------------------------------------|----------|---------------| +| localDir | Local Directory | Local directory to be watched | yes | n/a | +| listHiddenLocal | List Hidden | Allow listing of hidden files | yes | No | +| pollIntervalLocal | Polling interval, s | Interval for polling folder changes | yes | 60 | +| listRecursiveLocal | List Sub Folders | Allow listing of sub folders | yes | No | + +The `s3bucket` thing has the following configuration options: + +| Parameter | Name | Description | Required | Default value | +|----------------|----------------------|----------------------------------------------------|----------|---------------| +| s3BucketName | S3 Bucket Name | Name of the S3 bucket to be watched | yes | n/a | +| s3Path | S3 Path | S3 path (folder) to be monitored | no | n/a | +| pollIntervalS3 | Polling Interval | Interval for polling S3 bucket changes, in seconds | yes | 60 | +| awsKey | AWS Access Key | AWS access key | no | n/a | +| awsSecret | AWS Secret | AWS secret | no | n/a | +| awsRegion | AWS Region | AWS region of S3 bucket | yes | "" | +| s3Anonymous | Anonymous Connection | Connect anonymously (works for public buckets) | yes | true | ## Events This binding currently supports the following events: -| Channel Type ID | Item Type | Description | -|-----------------|--------------|----------------------------------------------------------------------------------------| -| newftpfile | String | A new file name discovered on FTP | -| newlocalfile | String | A new file name discovered on in local folder | +| Channel Type ID | Item Type | Description | +|-----------------|-----------|----------------------------| +| newfile | String | A new file name discovered | ## Full Example Thing configuration: ```java -folderwatcher:localfolder:myLocalFolder [ localDir="/myfolder", pollIntervalLocal=60, listHiddenLocal="false", listRecursiveLocal="false" ] -folderwatcher:ftpfolder:myLocalFolder [ ftpAddress="X.X.X.X", ftpPort=21, secureMode="EXPLICIT", ftpUsername="username", ftpPassword="password",ftpDir="/myfolder/",listHidden="true",listRecursiveFtp="true",connectionTimeout=33,pollInterval=66,diffHours=25] +folderwatcher:localfolder:myLocalFolder [ localDir="/myfolder", pollIntervalLocal=60, listHiddenLocal="false", listRecursiveLocal="false" ] +folderwatcher:ftpfolder:myLocalFolder [ ftpAddress="X.X.X.X", ftpPort=21, secureMode="EXPLICIT", ftpUsername="username", ftpPassword="password", ftpDir="/myfolder/", listHidden="true", listRecursiveFtp="true", connectionTimeout=33, pollInterval=66, diffHours=25 ] +folderwatcher:s3bucket:myS3bucket [ s3BucketName="mypublic-bucket", pollIntervalS3=60, awsRegion="us-west-1", s3Anonymous="true" ] + ``` ### Using in a rule: @@ -58,10 +70,10 @@ FTP example: ```java rule "New FTP file" when - Channel 'folderwatcher:ftpfolder:XXXXX:newfile' triggered + Channel "folderwatcher:ftpfolder:myLocalFolder:newfile" triggered then - logInfo('NewFTPFile', receivedEvent.toString()) + logInfo("NewFTPFile", receivedEvent.toString()) end ``` @@ -71,10 +83,23 @@ Local folder example: ```java rule "New Local file" when - Channel 'folderwatcher:localfolder:XXXXX:newfile' triggered + Channel "folderwatcher:localfolder:myFTPFolder:newfile" triggered +then + + logInfo("NewLocalFile", receivedEvent.toString()) + +end +``` + +S3 bucket example: + +```java +rule "New S3 file" +when + Channel "folderwatcher:s3bucket:myS3bucket:newfile" triggered then - logInfo('NewLocalFile', receivedEvent.toString()) + logInfo("NewS3File", receivedEvent.toString()) end ``` 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 4e2b42d23c..a9b0d3d296 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 @@ -26,5 +26,6 @@ public class FolderWatcherBindingConstants { private static final String BINDING_ID = "folderwatcher"; 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 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 ed37512cce..53c5263633 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 @@ -20,12 +20,16 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.binding.folderwatcher.internal.handler.FtpFolderWatcherHandler; import org.openhab.binding.folderwatcher.internal.handler.LocalFolderWatcherHandler; +import org.openhab.binding.folderwatcher.internal.handler.S3BucketWatcherHandler; +import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.binding.BaseThingHandlerFactory; import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; /** * The {@link FolderWatcherHandlerFactory} is responsible for creating things and thing @@ -38,7 +42,13 @@ import org.osgi.service.component.annotations.Component; public class FolderWatcherHandlerFactory extends BaseThingHandlerFactory { private static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_FTPFOLDER, - THING_TYPE_LOCALFOLDER); + THING_TYPE_LOCALFOLDER, THING_TYPE_S3BUCKET); + private HttpClientFactory httpClientFactory; + + @Activate + public FolderWatcherHandlerFactory(final @Reference HttpClientFactory httpClientFactory) { + this.httpClientFactory = httpClientFactory; + } @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { @@ -53,6 +63,8 @@ public class FolderWatcherHandlerFactory extends BaseThingHandlerFactory { return new FtpFolderWatcherHandler(thing); } else if (THING_TYPE_LOCALFOLDER.equals(thingTypeUID)) { return new LocalFolderWatcherHandler(thing); + } else if (THING_TYPE_S3BUCKET.equals(thingTypeUID)) { + return new S3BucketWatcherHandler(thing, httpClientFactory); } return null; } diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/S3Actions.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/S3Actions.java new file mode 100644 index 0000000000..c005092e6a --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/S3Actions.java @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2010-2023 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.AWS4SignerBase; +import org.openhab.binding.folderwatcher.internal.api.auth.AWS4SignerForAuthorizationHeader; +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 S3Actions} class contains AWS S3 API implementation. + * + * @author Alexandr Salamatov - Initial contribution + */ +@NonNullByDefault +public class S3Actions { + private final HttpClient httpClient; + private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(1); + private static final String CONTENT_TYPE = "application/xml"; + private URL bucketUri; + private String region; + private String awsAccessKey; + private String awsSecretKey; + + public S3Actions(HttpClientFactory httpClientFactory, String bucketName, String region) { + this(httpClientFactory, bucketName, region, "", ""); + } + + public S3Actions(HttpClientFactory httpClientFactory, String bucketName, String region, String awsAccessKey, + String awsSecretKey) { + this.httpClient = httpClientFactory.getCommonHttpClient(); + try { + this.bucketUri = new URL("http://" + bucketName + ".s3." + region + ".amazonaws.com"); + } catch (MalformedURLException e) { + throw new RuntimeException("Unable to parse service endpoint: " + e.getMessage()); + } + this.region = region; + this.awsAccessKey = awsAccessKey; + this.awsSecretKey = awsSecretKey; + } + + public List listBucket(String prefix) throws Exception { + Map headers = new HashMap(); + Map params = new HashMap(); + return listObjectsV2(prefix, headers, params); + } + + private List listObjectsV2(String prefix, Map headers, Map params) + throws Exception { + params.put("list-type", "2"); + params.put("prefix", prefix); + if (!awsAccessKey.isEmpty() || !awsSecretKey.isEmpty()) { + headers.put("x-amz-content-sha256", AWS4SignerBase.EMPTY_BODY_SHA256); + AWS4SignerForAuthorizationHeader signer = new AWS4SignerForAuthorizationHeader(this.bucketUri, "GET", "s3", + region); + String authorization = signer.computeSignature(headers, params, AWS4SignerBase.EMPTY_BODY_SHA256, + awsAccessKey, awsSecretKey); + headers.put("Authorization", authorization); + } + + headers.put(ACCEPT.toString(), CONTENT_TYPE); + Request request = httpClient.newRequest(this.bucketUri.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(); + InputSource is = new InputSource(new StringReader(contentResponse.getContentAsString())); + Document doc = docBuilder.parse(is); + NodeList nameNodesList = doc.getElementsByTagName("Key"); + List returnList = new ArrayList<>(); + + if (nameNodesList.getLength() == 0) { + throw new Exception("No files deceted in the bucket"); + } + + for (int i = 0; i < nameNodesList.getLength(); i++) { + returnList.add(nameNodesList.item(i).getFirstChild().getTextContent()); + } + + nameNodesList = doc.getElementsByTagName("IsTruncated"); + if (nameNodesList.getLength() > 0) { + if (nameNodesList.item(0).getFirstChild().getTextContent().equals("true")) { + nameNodesList = doc.getElementsByTagName("NextContinuationToken"); + if (nameNodesList.getLength() > 0) { + String continueToken = nameNodesList.item(0).getFirstChild().getTextContent(); + params.clear(); + headers.clear(); + params.put("continuation-token", continueToken); + returnList.addAll(listObjectsV2(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 new file mode 100644 index 0000000000..53c8cacae2 --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AWS4SignerBase.java @@ -0,0 +1,192 @@ +/** + * Copyright (c) 2010-2023 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.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.util.BinaryUtils; +import org.openhab.binding.folderwatcher.internal.api.util.HttpUtils; + +/** + * The {@link AWS4SignerBase} class contains based methods for AWS S3 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 AWS4SignerBase { + + 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 TERMINATOR = "aws4_request"; + public static final String ISO8601BasicFormat = "yyyyMMdd'T'HHmmss'Z'"; + public static final String DateStringFormat = "yyyyMMdd"; + protected URL endpointUrl; + protected String httpMethod; + protected String serviceName; + protected String regionName; + protected final SimpleDateFormat dateTimeFormat; + protected final SimpleDateFormat dateStampFormat; + + public AWS4SignerBase(URL endpointUrl, String httpMethod, String serviceName, String regionName) { + this.endpointUrl = endpointUrl; + this.httpMethod = httpMethod; + this.serviceName = serviceName; + this.regionName = regionName; + + dateTimeFormat = new SimpleDateFormat(ISO8601BasicFormat); + dateTimeFormat.setTimeZone(new SimpleTimeZone(0, "UTC")); + dateStampFormat = new SimpleDateFormat(DateStringFormat); + 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) { + String canonicalRequest = httpMethod + "\n" + getCanonicalizedResourcePath(endpoint) + "\n" + queryParameters + + "\n" + canonicalizedHeaders + "\n" + canonicalizedHeaderNames + "\n" + bodyHash; + return canonicalRequest; + } + + protected static String getCanonicalizedResourcePath(URL endpoint) { + 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) { + 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) { + String stringToSign = scheme + "-" + algorithm + "\n" + dateTime + "\n" + scope + "\n" + + BinaryUtils.toHex(hash(canonicalRequest)); + return stringToSign; + } + + public static byte[] hash(String text) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(text.getBytes("UTF-8")); + return md.digest(); + } catch (Exception e) { + throw new RuntimeException("Unable to compute hash while signing request: " + e.getMessage(), e); + } + } + + public static byte[] hash(byte[] data) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(data); + return md.digest(); + } catch (Exception e) { + throw new RuntimeException("Unable to compute hash while signing request: " + e.getMessage(), e); + } + } + + protected static byte[] sign(String stringData, byte[] key, String algorithm) { + 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 RuntimeException("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/AWS4SignerForAuthorizationHeader.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AWS4SignerForAuthorizationHeader.java new file mode 100644 index 0000000000..17a73a831f --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/auth/AWS4SignerForAuthorizationHeader.java @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2010-2023 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.Date; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.folderwatcher.internal.api.util.BinaryUtils; + +/** + * The {@link AWS4SignerForAuthorizationHeader} class contains methods for AWS S3 API authentication using HTTP(S) + * headers. + *

+ * 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 class AWS4SignerForAuthorizationHeader extends AWS4SignerBase { + + public AWS4SignerForAuthorizationHeader(URL endpointUrl, String httpMethod, String serviceName, String regionName) { + super(endpointUrl, httpMethod, serviceName, regionName); + } + + public String computeSignature(Map headers, Map queryParameters, String bodyHash, + String awsAccessKey, String awsSecretKey) { + Date now = new Date(); + String dateTimeStamp = dateTimeFormat.format(now); + headers.put("x-amz-date", dateTimeStamp); + String hostHeader = endpointUrl.getHost(); + int port = endpointUrl.getPort(); + if (port > -1) { + hostHeader.concat(":" + Integer.toString(port)); + } + headers.put("Host", hostHeader); + + String canonicalizedHeaderNames = getCanonicalizeHeaderNames(headers); + String canonicalizedHeaders = getCanonicalizedHeaderString(headers); + String canonicalizedQueryParameters = getCanonicalizedQueryString(queryParameters); + String canonicalRequest = getCanonicalRequest(endpointUrl, httpMethod, canonicalizedQueryParameters, + canonicalizedHeaderNames, canonicalizedHeaders, bodyHash); + String dateStamp = dateStampFormat.format(now); + String scope = dateStamp + "/" + regionName + "/" + serviceName + "/" + TERMINATOR; + String stringToSign = getStringToSign(SCHEME, ALGORITHM, dateTimeStamp, scope, canonicalRequest); + byte[] kSecret = (SCHEME + awsSecretKey).getBytes(); + byte[] kDate = sign(dateStamp, kSecret, "HmacSHA256"); + byte[] kRegion = sign(regionName, kDate, "HmacSHA256"); + byte[] kService = sign(serviceName, kRegion, "HmacSHA256"); + byte[] kSigning = sign(TERMINATOR, kService, "HmacSHA256"); + byte[] signature = sign(stringToSign, kSigning, "HmacSHA256"); + String credentialsAuthorizationHeader = "Credential=" + awsAccessKey + "/" + scope; + String signedHeadersAuthorizationHeader = "SignedHeaders=" + canonicalizedHeaderNames; + String signatureAuthorizationHeader = "Signature=" + BinaryUtils.toHex(signature); + String authorizationHeader = SCHEME + "-" + ALGORITHM + " " + credentialsAuthorizationHeader + ", " + + signedHeadersAuthorizationHeader + ", " + signatureAuthorizationHeader; + return authorizationHeader; + } +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/util/BinaryUtils.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/util/BinaryUtils.java new file mode 100644 index 0000000000..e2da533cbe --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/util/BinaryUtils.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2010-2023 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.util; + +import java.util.Locale; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link BinaryUtils} class contains methods for binary interactions. + *

+ * 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 class BinaryUtils { + public static String toHex(byte[] data) { + StringBuilder sb = new StringBuilder(data.length * 2); + for (int i = 0; i < data.length; i++) { + String hex = Integer.toHexString(data[i]); + if (hex.length() == 1) { + sb.append("0"); + } else if (hex.length() == 8) { + hex = hex.substring(6); + } + sb.append(hex); + } + return sb.toString().toLowerCase(Locale.getDefault()); + } + + public static byte[] fromHex(String hexData) { + byte[] result = new byte[(hexData.length() + 1) / 2]; + String hexNumber = null; + int stringOffset = 0; + int byteOffset = 0; + while (stringOffset < hexData.length()) { + hexNumber = hexData.substring(stringOffset, stringOffset + 2); + stringOffset += 2; + result[byteOffset++] = (byte) Integer.parseInt(hexNumber, 16); + } + return result; + } +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/util/HttpUtils.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/util/HttpUtils.java new file mode 100644 index 0000000000..bd323a2381 --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/api/util/HttpUtils.java @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2010-2023 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.util; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link HttpUtils} class contains metohdos related to HTTP(S). + *

+ * 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 class HttpUtils { + public static String urlEncode(String url, boolean keepPathSlash) { + String encoded; + try { + encoded = URLEncoder.encode(url, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("UTF-8 encoding is not supported.", e); + } + if (keepPathSlash) { + encoded = encoded.replace("%2F", "/"); + } + return encoded; + } +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/config/S3BucketWatcherConfiguration.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/config/S3BucketWatcherConfiguration.java new file mode 100644 index 0000000000..ecba148c01 --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/config/S3BucketWatcherConfiguration.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2023 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 LocalFolderWatcherConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Alexandr Salamatov - Initial contribution + */ +@NonNullByDefault +public class S3BucketWatcherConfiguration { + public String s3BucketName = ""; + public String s3Path = ""; + public boolean s3Anonymous; + public int pollIntervalS3; + public String awsKey = ""; + public String awsSecret = ""; + public String awsRegion = ""; +} diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/FtpFolderWatcherHandler.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/FtpFolderWatcherHandler.java index e057def94a..88d9f66884 100644 --- a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/FtpFolderWatcherHandler.java +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/FtpFolderWatcherHandler.java @@ -108,9 +108,11 @@ public class FtpFolderWatcherHandler extends BaseThingHandler { ScheduledFuture initJob = this.initJob; if (executionJob != null) { executionJob.cancel(true); + this.executionJob = null; } if (initJob != null) { initJob.cancel(true); + this.initJob = null; } if (ftp.isConnected()) { try { diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/LocalFolderWatcherHandler.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/LocalFolderWatcherHandler.java index 5cebce6398..2113b08785 100644 --- a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/LocalFolderWatcherHandler.java +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/LocalFolderWatcherHandler.java @@ -102,6 +102,7 @@ public class LocalFolderWatcherHandler extends BaseThingHandler { ScheduledFuture executionJob = this.executionJob; if (executionJob != null) { executionJob.cancel(true); + this.executionJob = null; } } diff --git a/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/S3BucketWatcherHandler.java b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/S3BucketWatcherHandler.java new file mode 100644 index 0000000000..f7e7148477 --- /dev/null +++ b/bundles/org.openhab.binding.folderwatcher/src/main/java/org/openhab/binding/folderwatcher/internal/handler/S3BucketWatcherHandler.java @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2010-2023 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.S3Actions; +import org.openhab.binding.folderwatcher.internal.common.WatcherCommon; +import org.openhab.binding.folderwatcher.internal.config.S3BucketWatcherConfiguration; +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 S3BucketWatcherHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Alexandr Salamatov - Initial contribution + */ +@NonNullByDefault +public class S3BucketWatcherHandler extends BaseThingHandler { + private final Logger logger = LoggerFactory.getLogger(S3BucketWatcherHandler.class); + private S3BucketWatcherConfiguration config = new S3BucketWatcherConfiguration(); + private File currentS3ListingFile = new File(OpenHAB.getUserDataFolder() + File.separator + "FolderWatcher" + + File.separator + thing.getUID().getAsString().replace(':', '_') + ".data"); + private @Nullable ScheduledFuture executionJob; + private List previousS3Listing = new ArrayList<>(); + private HttpClientFactory httpClientFactory; + private @Nullable S3Actions s3; + + public S3BucketWatcherHandler(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) { + refreshS3BucketInformation(); + } + } + + @Override + public void initialize() { + config = getConfigAs(S3BucketWatcherConfiguration.class); + + if (config.s3Anonymous) { + s3 = new S3Actions(httpClientFactory, config.s3BucketName, config.awsRegion); + } else { + s3 = new S3Actions(httpClientFactory, config.s3BucketName, config.awsRegion, config.awsKey, + config.awsSecret); + } + + try { + previousS3Listing = WatcherCommon.initStorage(currentS3ListingFile, config.s3BucketName); + } catch (IOException e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage()); + logger.debug("Can't write file {}: {}", currentS3ListingFile, e.getMessage()); + return; + } + + if (refreshS3BucketInformation()) { + if (config.pollIntervalS3 > 0) { + updateStatus(ThingStatus.ONLINE); + executionJob = scheduler.scheduleWithFixedDelay(this::refreshS3BucketInformation, config.pollIntervalS3, + config.pollIntervalS3, TimeUnit.SECONDS); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "Polling interval must be greater then 0 seconds"); + return; + } + } + } + + private boolean refreshS3BucketInformation() { + List currentS3Listing = new ArrayList<>(); + try { + currentS3Listing = s3.listBucket(config.s3Path); + List difS3Listing = new ArrayList<>(currentS3Listing); + difS3Listing.removeAll(previousS3Listing); + difS3Listing.forEach(file -> triggerChannel(CHANNEL_NEWFILE, file)); + + if (!difS3Listing.isEmpty()) { + WatcherCommon.saveNewListing(difS3Listing, currentS3ListingFile); + } + previousS3Listing = new ArrayList<>(currentS3Listing); + } catch (Exception e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Can't connect to the bucket"); + logger.debug("Can't connect to the bucket: {}", 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 5bcc4a0f43..15496e6883 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 @@ -9,11 +9,13 @@ thing-type.folderwatcher.ftpfolder.label = FTP Folder thing-type.folderwatcher.ftpfolder.description = FTP folder to be watched thing-type.folderwatcher.localfolder.label = Local Folder thing-type.folderwatcher.localfolder.description = Local folder to be watched +thing-type.folderwatcher.s3bucket.label = AWS S3 Bucket +thing-type.folderwatcher.s3bucket.description = AWS S3 bucket to be watched # thing types config thing-type.config.folderwatcher.ftpfolder.connectionTimeout.label = Connection Timeout -thing-type.config.folderwatcher.ftpfolder.connectionTimeout.description = Connection timeout for FTP request, sec +thing-type.config.folderwatcher.ftpfolder.connectionTimeout.description = Connection timeout for FTP request, in seconds thing-type.config.folderwatcher.ftpfolder.diffHours.label = Timestamp Difference thing-type.config.folderwatcher.ftpfolder.diffHours.description = How many hours back to analyze thing-type.config.folderwatcher.ftpfolder.ftpAddress.label = FTP Server @@ -31,7 +33,7 @@ thing-type.config.folderwatcher.ftpfolder.listHidden.description = Allow listing thing-type.config.folderwatcher.ftpfolder.listRecursiveFtp.label = List Sub Folders thing-type.config.folderwatcher.ftpfolder.listRecursiveFtp.description = Allow listing of sub folders thing-type.config.folderwatcher.ftpfolder.pollInterval.label = Polling Interval -thing-type.config.folderwatcher.ftpfolder.pollInterval.description = Interval for polling folder changes, sec +thing-type.config.folderwatcher.ftpfolder.pollInterval.description = Interval for polling folder changes, in seconds thing-type.config.folderwatcher.ftpfolder.secureMode.label = FTP Security thing-type.config.folderwatcher.ftpfolder.secureMode.description = FTP Security settings thing-type.config.folderwatcher.ftpfolder.secureMode.option.NONE = None @@ -44,7 +46,21 @@ thing-type.config.folderwatcher.localfolder.listRecursiveLocal.description = All thing-type.config.folderwatcher.localfolder.localDir.label = Local Directory thing-type.config.folderwatcher.localfolder.localDir.description = Local directory to be watched thing-type.config.folderwatcher.localfolder.pollIntervalLocal.label = Polling Interval -thing-type.config.folderwatcher.localfolder.pollIntervalLocal.description = Interval for polling folder changes, sec +thing-type.config.folderwatcher.localfolder.pollIntervalLocal.description = Interval for polling folder changes, in seconds +thing-type.config.folderwatcher.s3bucket.awsKey.label = AWS Access Key +thing-type.config.folderwatcher.s3bucket.awsKey.description = AWS access key +thing-type.config.folderwatcher.s3bucket.awsRegion.label = AWS Region +thing-type.config.folderwatcher.s3bucket.awsRegion.description = AWS region of S3 bucket +thing-type.config.folderwatcher.s3bucket.awsSecret.label = AWS Secret +thing-type.config.folderwatcher.s3bucket.awsSecret.description = AWS secret +thing-type.config.folderwatcher.s3bucket.pollIntervalS3.label = Polling Interval +thing-type.config.folderwatcher.s3bucket.pollIntervalS3.description = Interval for polling S3 bucket changes, in seconds +thing-type.config.folderwatcher.s3bucket.s3Anonymous.label = Anonymous Connection +thing-type.config.folderwatcher.s3bucket.s3Anonymous.description = Connect anonymously (works for public buckets) +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 # 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 1a9dc7d18f..825acecb91 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 @@ -62,13 +62,13 @@ - Connection timeout for FTP request, sec + Connection timeout for FTP request, in seconds 30 true - Interval for polling folder changes, sec + Interval for polling folder changes, in seconds 60 true @@ -105,7 +105,7 @@ - Interval for polling folder changes, sec + Interval for polling folder changes, in seconds 60 true @@ -123,4 +123,50 @@ + + + AWS S3 bucket to be watched + + + + + + + + + Name of the S3 bucket to be watched + + + + S3 path (folder) to be monitored + + + + AWS region of S3 bucket + + + + Interval for polling S3 bucket changes, in seconds + 60 + true + + + + false + Connect anonymously (works for public buckets) + true + + + + AWS access key + true + + + + AWS secret + password + true + + + -- 2.47.3