@daisy-workflow/plugin-aws-s3 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/sigv4.js ADDED
@@ -0,0 +1,176 @@
1
+ // AWS Signature Version 4 — minimal implementation supporting both
2
+ // header-based auth (Authorization header) and query-string auth
3
+ // (presigned URLs).
4
+ //
5
+ // Spec: https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
6
+ // Validated against AWS's published S3 test vector
7
+ // ("Example: GET Object" — examplebucket/test.txt).
8
+ //
9
+ // Built with node:crypto — no external deps.
10
+
11
+ import crypto from "node:crypto";
12
+
13
+ const ALGO = "AWS4-HMAC-SHA256";
14
+ const TERMINATOR = "aws4_request";
15
+ const SERVICE = "s3";
16
+
17
+ const sha256Hex = (data) =>
18
+ crypto.createHash("sha256").update(data).digest("hex");
19
+ const hmac = (key, data) =>
20
+ crypto.createHmac("sha256", key).update(data).digest();
21
+
22
+ // RFC 3986 URI encoding. encodeURIComponent already does most of this;
23
+ // patch up the characters it leaves alone but the spec says to escape.
24
+ function uriEncode(str) {
25
+ return encodeURIComponent(str)
26
+ .replace(/!/g, "%21")
27
+ .replace(/\*/g, "%2A")
28
+ .replace(/'/g, "%27")
29
+ .replace(/\(/g, "%28")
30
+ .replace(/\)/g, "%29");
31
+ }
32
+
33
+ function canonicalUri(pathname) {
34
+ if (!pathname || pathname === "/") return "/";
35
+ return pathname
36
+ .split("/")
37
+ .map(seg => uriEncode(decodeURIComponent(seg)))
38
+ .join("/");
39
+ }
40
+
41
+ function canonicalQuery(searchParams) {
42
+ if (!searchParams) return "";
43
+ const entries = [];
44
+ for (const [k, v] of searchParams) entries.push([k, v]);
45
+ entries.sort((a, b) => a[0] === b[0] ? (a[1] < b[1] ? -1 : 1) : (a[0] < b[0] ? -1 : 1));
46
+ return entries.map(([k, v]) => `${uriEncode(k)}=${uriEncode(v ?? "")}`).join("&");
47
+ }
48
+
49
+ function canonicalHeaders(headers) {
50
+ const lower = {};
51
+ for (const [k, v] of Object.entries(headers)) {
52
+ if (v == null) continue;
53
+ lower[k.toLowerCase()] = String(v).trim().replace(/\s+/g, " ");
54
+ }
55
+ const keys = Object.keys(lower).sort();
56
+ const canonical = keys.map(k => `${k}:${lower[k]}\n`).join("");
57
+ const signed = keys.join(";");
58
+ return { canonical, signed };
59
+ }
60
+
61
+ function derivSigningKey(secretAccessKey, dateStamp, region, service) {
62
+ const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
63
+ const kRegion = hmac(kDate, region);
64
+ const kService = hmac(kRegion, service);
65
+ return hmac(kService, TERMINATOR);
66
+ }
67
+
68
+ function amzTimestamp(d) {
69
+ return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
70
+ }
71
+
72
+ // ── header-based signing ──────────────────────────────────────────────
73
+ // Returns headers to send (caller headers + Authorization + amz-date +
74
+ // content-sha256, plus security-token if STS).
75
+ export function signRequest({
76
+ method, url, headers = {}, body = "",
77
+ region, service = SERVICE,
78
+ accessKeyId, secretAccessKey, sessionToken = null,
79
+ nowMs,
80
+ }) {
81
+ const u = new URL(url);
82
+ const now = new Date(nowMs ?? Date.now());
83
+ const amzDate = amzTimestamp(now);
84
+ const dateStamp = amzDate.slice(0, 8);
85
+
86
+ const payloadHash = sha256Hex(body || "");
87
+
88
+ const signedHeaders = {
89
+ ...headers,
90
+ host: u.host,
91
+ "x-amz-date": amzDate,
92
+ "x-amz-content-sha256": payloadHash,
93
+ };
94
+ if (sessionToken) signedHeaders["x-amz-security-token"] = sessionToken;
95
+
96
+ const { canonical: canonHeaders, signed: signedHeaderList } = canonicalHeaders(signedHeaders);
97
+
98
+ const canonicalRequest = [
99
+ method.toUpperCase(),
100
+ canonicalUri(u.pathname),
101
+ canonicalQuery(u.searchParams),
102
+ canonHeaders,
103
+ signedHeaderList,
104
+ payloadHash,
105
+ ].join("\n");
106
+
107
+ const credentialScope = `${dateStamp}/${region}/${service}/${TERMINATOR}`;
108
+ const stringToSign = [ALGO, amzDate, credentialScope, sha256Hex(canonicalRequest)].join("\n");
109
+
110
+ const kSigning = derivSigningKey(secretAccessKey, dateStamp, region, service);
111
+ const signature = hmac(kSigning, stringToSign).toString("hex");
112
+
113
+ const authorization = [
114
+ `${ALGO} Credential=${accessKeyId}/${credentialScope}`,
115
+ `SignedHeaders=${signedHeaderList}`,
116
+ `Signature=${signature}`,
117
+ ].join(", ");
118
+
119
+ return { ...signedHeaders, Authorization: authorization };
120
+ }
121
+
122
+ // ── presigned URL signing ─────────────────────────────────────────────
123
+ // Returns a URL with X-Amz-* query params baked in. The caller can use
124
+ // it from a browser / curl / IoT device without holding the secret.
125
+ //
126
+ // Notes:
127
+ // • For presigned URLs, x-amz-content-sha256 is the literal string
128
+ // "UNSIGNED-PAYLOAD" (S3 special-cases this).
129
+ // • Only `host` is in SignedHeaders by default. If you need to enforce
130
+ // specific headers at request time, add them to `headers`.
131
+ // • Max expiry is 7 days (604800s) for SigV4.
132
+ export function presignUrl({
133
+ method = "GET", url, headers = {}, expiresIn = 900,
134
+ region, service = SERVICE,
135
+ accessKeyId, secretAccessKey, sessionToken = null,
136
+ nowMs,
137
+ }) {
138
+ if (expiresIn < 1 || expiresIn > 604800) {
139
+ throw new Error(`presignUrl: expiresIn must be 1..604800 (got ${expiresIn})`);
140
+ }
141
+ const u = new URL(url);
142
+ const now = new Date(nowMs ?? Date.now());
143
+ const amzDate = amzTimestamp(now);
144
+ const dateStamp = amzDate.slice(0, 8);
145
+ const credentialScope = `${dateStamp}/${region}/${service}/${TERMINATOR}`;
146
+
147
+ // Sign at minimum `host` (every request has it). Extra headers can be
148
+ // forced via `headers`.
149
+ const signedHeaders = { ...headers, host: u.host };
150
+ const { canonical: canonHeaders, signed: signedHeaderList } = canonicalHeaders(signedHeaders);
151
+
152
+ // Add the X-Amz-* query parameters BEFORE building the canonical
153
+ // query string (they participate in the signature).
154
+ u.searchParams.set("X-Amz-Algorithm", ALGO);
155
+ u.searchParams.set("X-Amz-Credential", `${accessKeyId}/${credentialScope}`);
156
+ u.searchParams.set("X-Amz-Date", amzDate);
157
+ u.searchParams.set("X-Amz-Expires", String(expiresIn));
158
+ u.searchParams.set("X-Amz-SignedHeaders", signedHeaderList);
159
+ if (sessionToken) u.searchParams.set("X-Amz-Security-Token", sessionToken);
160
+
161
+ const canonicalRequest = [
162
+ method.toUpperCase(),
163
+ canonicalUri(u.pathname),
164
+ canonicalQuery(u.searchParams),
165
+ canonHeaders,
166
+ signedHeaderList,
167
+ "UNSIGNED-PAYLOAD",
168
+ ].join("\n");
169
+
170
+ const stringToSign = [ALGO, amzDate, credentialScope, sha256Hex(canonicalRequest)].join("\n");
171
+ const kSigning = derivSigningKey(secretAccessKey, dateStamp, region, service);
172
+ const signature = hmac(kSigning, stringToSign).toString("hex");
173
+
174
+ u.searchParams.set("X-Amz-Signature", signature);
175
+ return u.toString();
176
+ }
package/manifest.json ADDED
@@ -0,0 +1,185 @@
1
+ {
2
+ "name": "aws-s3",
3
+ "version": "0.1.0",
4
+ "description": "AWS S3 connector — buckets, files, folders, plus AWS-specific features (storage classes, server-side encryption, KMS, tags, requester pays, presigned URLs). For non-AWS S3-compatible providers (Wasabi, MinIO, R2, B2…) use the generic `s3` plugin. Mirrors n8n's AWS S3 node. Auth via a workspace `generic` config with accessKeyId + secretAccessKey + region (plus optional sessionToken / customEndpoint).",
5
+ "ui": { "category": "storage", "icon": "amazon-s3" },
6
+ "primaryOutput": "result",
7
+
8
+ "configRefs": [
9
+ {
10
+ "name": "aws-s3",
11
+ "type": "generic",
12
+ "required": true,
13
+ "description": "Generic config: { accessKeyId, secretAccessKey, region, sessionToken?, customEndpoint? }. The endpoint is derived from the region (s3.<region>.amazonaws.com) unless customEndpoint is set — useful for VPC interface endpoints, S3 Transfer Acceleration, AWS GovCloud, or AWS China regions."
14
+ }
15
+ ],
16
+
17
+ "inputSchema": {
18
+ "type": "object",
19
+ "required": ["operation"],
20
+ "properties": {
21
+ "operation": {
22
+ "type": "string",
23
+ "description": "Which AWS S3 call to run.",
24
+ "enum": [
25
+ "bucket.getAll",
26
+ "bucket.create",
27
+ "bucket.delete",
28
+ "bucket.search",
29
+ "bucket.location",
30
+ "file.getAll",
31
+ "file.head",
32
+ "file.upload",
33
+ "file.download",
34
+ "file.copy",
35
+ "file.delete",
36
+ "file.presignedUrl",
37
+ "folder.create",
38
+ "folder.getAll",
39
+ "folder.delete"
40
+ ]
41
+ },
42
+
43
+ "config": {
44
+ "type": "string",
45
+ "description": "Workspace config name to read credentials from. Default: 'aws-s3'."
46
+ },
47
+
48
+ "bucket": {
49
+ "type": "string",
50
+ "description": "Bucket name. Required for bucket.create / bucket.delete / bucket.search / bucket.location / file.* / folder.*."
51
+ },
52
+ "key": {
53
+ "type": "string",
54
+ "description": "Object key (path within the bucket). Required for file.{upload,download,copy,delete,head,presignedUrl}."
55
+ },
56
+
57
+ "prefix": {
58
+ "type": "string",
59
+ "description": "Key prefix filter. Used by bucket.search / file.getAll / folder.getAll / folder.delete."
60
+ },
61
+ "delimiter": {
62
+ "type": "string",
63
+ "default": "/",
64
+ "description": "Delimiter used to group keys into 'folders'. Default '/'. Used by folder.getAll."
65
+ },
66
+ "maxKeys": {
67
+ "type": "integer",
68
+ "minimum": 1,
69
+ "maximum": 1000,
70
+ "default": 1000,
71
+ "description": "Page size for list operations (S3 caps this at 1000)."
72
+ },
73
+ "continuationToken": {
74
+ "type": "string",
75
+ "description": "Opaque pagination token from a previous list response."
76
+ },
77
+
78
+ "body": {
79
+ "type": "string",
80
+ "description": "Object content for file.upload. Plain text by default; set `bodyEncoding: 'base64'` to upload binary data."
81
+ },
82
+ "bodyEncoding": {
83
+ "type": "string",
84
+ "enum": ["utf8", "base64"],
85
+ "default": "utf8",
86
+ "description": "How to interpret `body`. Use 'base64' to upload binary files."
87
+ },
88
+ "contentType": {
89
+ "type": "string",
90
+ "description": "Content-Type header for file.upload. Default: application/octet-stream (binary) or text/plain (utf8)."
91
+ },
92
+ "metadata": {
93
+ "type": "object",
94
+ "description": "User metadata for file.upload. Each key is sent as x-amz-meta-<key>."
95
+ },
96
+ "tags": {
97
+ "type": "object",
98
+ "description": "Object tags for file.upload. URL-encoded into the x-amz-tagging header (e.g. {env:'prod', team:'data'} → 'env=prod&team=data')."
99
+ },
100
+
101
+ "acl": {
102
+ "type": "string",
103
+ "enum": ["private", "public-read", "public-read-write", "authenticated-read", "aws-exec-read", "bucket-owner-read", "bucket-owner-full-control"],
104
+ "description": "Canned ACL. Used by file.upload / folder.create / bucket.create. Note: many AWS accounts have ACLs disabled via 'Object Ownership = Bucket owner enforced'; in that case the ACL header is rejected."
105
+ },
106
+
107
+ "storageClass": {
108
+ "type": "string",
109
+ "enum": ["STANDARD", "REDUCED_REDUNDANCY", "STANDARD_IA", "ONEZONE_IA", "INTELLIGENT_TIERING", "GLACIER", "DEEP_ARCHIVE", "GLACIER_IR"],
110
+ "description": "Storage class for file.upload. AWS S3-specific. GLACIER and DEEP_ARCHIVE need restore-before-read."
111
+ },
112
+
113
+ "serverSideEncryption": {
114
+ "type": "string",
115
+ "enum": ["AES256", "aws:kms", "aws:kms:dsse"],
116
+ "description": "Server-side encryption for file.upload. Set ssekmsKeyId when using aws:kms or aws:kms:dsse."
117
+ },
118
+ "ssekmsKeyId": {
119
+ "type": "string",
120
+ "description": "KMS key ID or ARN. Required when serverSideEncryption is aws:kms / aws:kms:dsse and you want a non-default key."
121
+ },
122
+
123
+ "requesterPays": {
124
+ "type": "boolean",
125
+ "default": false,
126
+ "description": "Set x-amz-request-payer: requester. Required when reading from or writing to a bucket configured as Requester Pays."
127
+ },
128
+
129
+ "copySource": {
130
+ "type": "string",
131
+ "description": "Source for file.copy in `bucket/key` form (e.g. 'src-bucket/path/to/file.txt'). Destination is bucket+key."
132
+ },
133
+
134
+ "folderName": {
135
+ "type": "string",
136
+ "description": "Folder name for folder.create. Will be created as an empty object with key ending in '/'."
137
+ },
138
+
139
+ "responseEncoding": {
140
+ "type": "string",
141
+ "enum": ["utf8", "base64"],
142
+ "default": "base64",
143
+ "description": "How file.download returns the body. 'base64' is safe for binary. 'utf8' for text-only objects."
144
+ },
145
+
146
+ "presignedMethod": {
147
+ "type": "string",
148
+ "enum": ["GET", "PUT"],
149
+ "default": "GET",
150
+ "description": "HTTP method to sign for file.presignedUrl."
151
+ },
152
+ "presignedExpiresIn": {
153
+ "type": "integer",
154
+ "minimum": 1,
155
+ "maximum": 604800,
156
+ "default": 900,
157
+ "description": "Expiry in seconds for file.presignedUrl. Max 604800 (7 days)."
158
+ },
159
+
160
+ "region": {
161
+ "type": "string",
162
+ "description": "Override the region from the config for this call only."
163
+ },
164
+
165
+ "timeoutMs": {
166
+ "type": "integer",
167
+ "minimum": 1,
168
+ "maximum": 120000,
169
+ "default": 30000
170
+ }
171
+ }
172
+ },
173
+
174
+ "outputSchema": {
175
+ "type": "object",
176
+ "required": ["ok", "operation"],
177
+ "properties": {
178
+ "ok": { "type": "boolean" },
179
+ "operation": { "type": "string", "description": "Echo of the input operation that ran." },
180
+ "status": { "type": "integer", "description": "HTTP status from the underlying S3 call (omitted for file.presignedUrl)." },
181
+ "result": { "description": "Operation-specific payload. See README for shapes." },
182
+ "url": { "type": "string", "description": "Deep-link to the affected bucket/object on the AWS endpoint (when applicable)." }
183
+ }
184
+ }
185
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@daisy-workflow/plugin-aws-s3",
3
+ "version": "0.1.0",
4
+ "description": "Daisy external plugin — AWS S3 connector (AWS-specific: region-derived endpoints, storage classes, server-side encryption, KMS, tags, requester pays, presigned URLs).",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "start": "node index.js"
9
+ },
10
+ "dependencies": {
11
+ "@daisy-workflow/plugin-sdk": "^0.1.0"
12
+ },
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "license": "MIT"
17
+ }
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # publish-docker.sh — build & push the aws-s3 plugin image to Docker Hub.
4
+ #
5
+ # Usage:
6
+ # ./publish-docker.sh # build + push :<version> and :latest, multi-arch
7
+ # IMAGE=foo/bar ./publish-docker.sh # override image name
8
+ # PLATFORMS=linux/amd64 ./publish-docker.sh # single-arch
9
+ # PUSH=0 ./publish-docker.sh # build only, don't push
10
+ # NO_LATEST=1 ./publish-docker.sh # skip the :latest tag
11
+ #
12
+ # Prereqs on your machine:
13
+ # - Docker Desktop (or dockerd) running
14
+ # - docker buildx available (comes with Docker Desktop)
15
+ # - You're logged in: docker login -u <dockerhub-user>
16
+ #
17
+ set -euo pipefail
18
+
19
+ IMAGE="${IMAGE:-vivek13186/daisy-plugin-aws-s3}"
20
+ PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
21
+ PUSH="${PUSH:-1}"
22
+ NO_LATEST="${NO_LATEST:-0}"
23
+
24
+ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
25
+ cd "$SCRIPT_DIR"
26
+
27
+ if ! command -v node >/dev/null 2>&1; then
28
+ echo "ERROR: node is required to read version from package.json" >&2
29
+ exit 1
30
+ fi
31
+ VERSION="$(node -p "require('./package.json').version")"
32
+ if [[ -z "$VERSION" || "$VERSION" == "undefined" ]]; then
33
+ echo "ERROR: could not read version from package.json" >&2
34
+ exit 1
35
+ fi
36
+
37
+ if ! command -v docker >/dev/null 2>&1; then
38
+ echo "ERROR: docker CLI not found in PATH" >&2
39
+ exit 1
40
+ fi
41
+ if ! docker buildx version >/dev/null 2>&1; then
42
+ echo "ERROR: docker buildx is required (install Docker Desktop or the buildx plugin)" >&2
43
+ exit 1
44
+ fi
45
+
46
+ BUILDER="daisy-plugin-builder"
47
+ if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
48
+ echo ">> creating buildx builder: $BUILDER"
49
+ docker buildx create --name "$BUILDER" --driver docker-container --use >/dev/null
50
+ else
51
+ docker buildx use "$BUILDER" >/dev/null
52
+ fi
53
+ docker buildx inspect --bootstrap >/dev/null
54
+
55
+ TAG_LIST=("${IMAGE}:${VERSION}")
56
+ if [[ "$NO_LATEST" != "1" ]]; then
57
+ TAG_LIST+=("${IMAGE}:latest")
58
+ fi
59
+ TAG_FLAGS=()
60
+ for t in "${TAG_LIST[@]}"; do
61
+ TAG_FLAGS+=(--tag "$t")
62
+ done
63
+
64
+ PUSH_FLAG="--push"
65
+ if [[ "$PUSH" == "0" ]]; then
66
+ PUSH_FLAG="--load"
67
+ if [[ "$PLATFORMS" == *","* ]]; then
68
+ echo ">> PUSH=0 requested; forcing single platform linux/amd64 (--load can't load multi-arch)"
69
+ PLATFORMS="linux/amd64"
70
+ fi
71
+ fi
72
+
73
+ echo ">> image: $IMAGE"
74
+ echo ">> version: $VERSION"
75
+ echo ">> platforms: $PLATFORMS"
76
+ echo ">> tags: ${TAG_LIST[*]}"
77
+ echo ">> action: $([[ "$PUSH" == "1" ]] && echo push || echo build-only)"
78
+
79
+ if [[ "$PUSH" == "1" ]]; then
80
+ if ! docker info 2>/dev/null | grep -q "Username:"; then
81
+ echo ">> not logged in to Docker Hub — running: docker login"
82
+ docker login
83
+ fi
84
+ fi
85
+
86
+ docker buildx build \
87
+ --platform "$PLATFORMS" \
88
+ "${TAG_FLAGS[@]}" \
89
+ --file Dockerfile \
90
+ $PUSH_FLAG \
91
+ .
92
+
93
+ echo ">> done."
94
+ if [[ "$PUSH" == "1" ]]; then
95
+ echo ">> pulled with: docker pull ${IMAGE}:${VERSION}"
96
+ echo ">> hub page: https://hub.docker.com/r/${IMAGE}"
97
+ fi