dxfl 0.1.8 → 0.1.10

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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # v0.1.10
2
+
3
+ - new `logout` command: removes dxfl config folder containing the credentials
4
+ - rename authentification config folder from "dfl" to "dxfl", for consistency and clarity
5
+ - `deuxfleurs.toml`: make "error.html" the default `error_page`'s value
6
+ - `deploy`/`empty` commands can now be used without being logged, by passing S3 id and secret keys as enviromnent variables (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` respectively)
7
+ - `deuxfleurs.toml` can now configure cross-origin requests
8
+
9
+ # v0.1.9
10
+
11
+ - `deuxfleurs.toml`: rename redirection property `if_error = 404` to `force = true`, to simplify and improve comprehension.
12
+
13
+ # v0.1.8
14
+
1
15
  - `deploy`: add support for redirects, configured from a new `deuxfleurs.toml` file.
2
16
  - supports S3 "object redirects" (redirects from a path in the website)
3
17
  - supports global "bucket redirects" (redirects from any path in the website
package/README.md CHANGED
@@ -10,9 +10,9 @@ npm install -g dxfl
10
10
 
11
11
  ## Usage
12
12
 
13
- _Not ready_
13
+ _Warning: software still in an experimental state_
14
14
 
15
- Start by login with your username, for example for `john`:
15
+ Start by logging in with your username, for example for `john`:
16
16
 
17
17
  ```
18
18
  dxfl login john
@@ -30,13 +30,19 @@ And then to deploy your `_public` folder on `example.com`:
30
30
  dxfl deploy example.com _public
31
31
  ```
32
32
 
33
- Or potentially preview the upcoming changes with the `--dry-run` option:
33
+ To deploy without confirmation, use the `--yes` option:
34
+
35
+ ```
36
+ dxfl deploy example.com _public --yes
37
+ ```
38
+
39
+ Or only display the changes without applying them, with the `--dry-run` option:
34
40
 
35
41
  ```
36
42
  dxfl deploy example.com _public --dry-run
37
43
  ```
38
44
 
39
- If you need to empty a website from its contents:
45
+ Use the `empty` command to delete all files from a website (required before deleting it):
40
46
 
41
47
  ```
42
48
  dxfl empty example.com
@@ -45,7 +51,7 @@ dxfl empty example.com
45
51
  ## Website configuration
46
52
 
47
53
  `dxfl deploy` reads a `deuxfleurs.toml` configuration file (if it exists in the current directory).
48
- This file can be used to specify website configuration metadata, such as redirections.
54
+ This file can be used to specify website configuration metadata such as redirections.
49
55
 
50
56
  Your `deuxfleurs.toml` should follow the following structure:
51
57
 
@@ -53,9 +59,9 @@ Your `deuxfleurs.toml` should follow the following structure:
53
59
  # Filename added after URLs that point to directories.
54
60
  # Default value: "index.html"
55
61
  index_page = "index.html"
56
- # Path to the file to serve in case of 404 error
57
- # Default value: none.
58
- error_page = "404.html"
62
+ # Path to the file to serve in case of 404 error.
63
+ # Default value: "error.html"
64
+ error_page = "error.html"
59
65
 
60
66
  # A redirect entry. There can be as many as desired.
61
67
  [[redirects]]
@@ -75,10 +81,66 @@ from = "foobar/*"
75
81
  # single path.
76
82
  to = "baz/*"
77
83
  # Optional: custom HTTP status code for the redirection.
78
- status = 302
79
- # Optional: only redirect if the original request triggered a 404 error
80
- if_error = 404
81
- ```
84
+ # Default value: 302
85
+ status = 301
86
+ # Optional: always apply the redirect.
87
+ # By default, redirections that match multiple paths do not apply
88
+ # if a file that matches the requested path could be served instead.
89
+ # Setting this option to true overrides this behavior and always
90
+ # applies the redirection.
91
+ # Default value: false
92
+ force = true
93
+
94
+ # Configuration to allow cross-origin requests.
95
+ # Multiple rules can be specified and will be matched in order.
96
+ # Each rule is defined in its own `[[cors]]` block
97
+ [[cors]]
98
+ # Origins that you want to allow cross-origin requests from.
99
+ allowed_origins = "https://www.example.com"
100
+ # Possible values:
101
+ # - allowed_origins = "*" : wildcard to allow all origins
102
+ # - allowed_origins = "https://www.example.com" : string of origin (protocol+domain)
103
+ # - allowed_origins = ["https://example1.com", "https://example2.com"] : array of origins
104
+ # Required parameter.
105
+
106
+ # HTTP methods allowed in a cross-origin request (in response to a preflight request).
107
+ allowed_method = "GET"
108
+ # Possible values:
109
+ # - allowed_method = "*" : wild card to allow all methods (only GET/HEAD are currently supported by the server)
110
+ # - allowed_method = "GET" : string of http method ("GET" or "HEAD")
111
+ # - allowed_method = ["GET", "HEAD"] : array of string http methods
112
+ # Optional parameter. Default value: ["GET", "HEAD"]
113
+
114
+ # Request headers allowed in a preflight request.
115
+ allowed_header = "Authorization"
116
+ # Possible values:
117
+ # - allowed_header = "*" : wildcard to allow all headers
118
+ # - allowed_header = "Authorization" : string of http header
119
+ # - allowed_header = ["Authorization", "Age"] : array of string http header
120
+ # Optional parameter. Default value : []
121
+
122
+ # Response headers you want to make available to JavaScript in response to a cross-origin request.
123
+ expose_header = "Content-Encoding"
124
+ # Possible values:
125
+ # - expose_header = "Content-Encoding" : string of http header
126
+ # - expose_header = ["Content-Encoding", "Kuma-Revision"] : array of string http header
127
+ # Optional Parameter. Default value : []
128
+ ```
129
+
130
+ ## Using dxfl in automated deployments
131
+
132
+ `dxfl login` is designed for interactive use, where you type the password in
133
+ your local computer. For automated deployments (e.g. deploying your website in a
134
+ continuous integration workflows), you can instead directly call `dxfl deploy`
135
+ (or `dxfl empty`) and pass website credentials using environment variables:
136
+
137
+ - `AWS_ACCESS_KEY_ID` for the S3 key id;
138
+ - `AWS_SECRET_ACCESS_KEY` for the S3 secret key.
139
+
140
+ The values for these variables can be found in the Guichet web interface for
141
+ your website in the "S3" tab.
142
+
143
+ Config examples for [Woodpecker CI](doc/workflows/woodpecker.yml) and [GitHub Actions](doc/workflows/gh-actions.yml) are also available to help you get started.
82
144
 
83
145
  ## Development
84
146
 
@@ -113,6 +175,7 @@ Do not forget also to run `npm login` to bind your account with the CLI.
113
175
  Then to publish a release:
114
176
 
115
177
  ```bash
178
+ vim CHANGELOG.md # update the version and its content in this file
116
179
  vim package.json # update the version in this file
117
180
  vim index.ts # update the version in this file
118
181
  npm install # update the version in the package-lock.json
package/dist/auth.js CHANGED
@@ -11,16 +11,62 @@ import { Configuration, WebsiteApi } from "guichet-sdk-ts";
11
11
  import { read } from "read";
12
12
  import path from "node:path";
13
13
  import fs from "node:fs/promises";
14
+ import { confirmationPrompt } from "./utils.js";
14
15
  function configPath() {
15
- let path = ".dfl/config.json";
16
+ let path = ".dxfl/config.json";
16
17
  if (process.env.XDG_CONFIG_HOME) {
17
- path = process.env.XDG_CONFIG_HOME + "/dfl/config.json";
18
+ path = process.env.XDG_CONFIG_HOME + "/dxfl/config.json";
18
19
  }
19
20
  else if (process.env.HOME) {
20
- path = process.env.HOME + "/.config/dfl/config.json";
21
+ path = process.env.HOME + "/.config/dxfl/config.json";
21
22
  }
22
23
  return path;
23
24
  }
25
+ // Rename old config folder (from dfl to dxfl)
26
+ function moveOldConfig() {
27
+ return __awaiter(this, void 0, void 0, function* () {
28
+ function oldConfigPath() {
29
+ let path = ".dfl/config.json";
30
+ if (process.env.XDG_CONFIG_HOME) {
31
+ path = process.env.XDG_CONFIG_HOME + "/dfl/config.json";
32
+ }
33
+ else if (process.env.HOME) {
34
+ path = process.env.HOME + "/.config/dfl/config.json";
35
+ }
36
+ return path;
37
+ }
38
+ const oldConfigFile = oldConfigPath();
39
+ try {
40
+ yield fs.access(oldConfigFile);
41
+ try {
42
+ const newConfigFile = configPath();
43
+ const oldConfigParent = path.dirname(oldConfigFile);
44
+ const newConfigParent = path.dirname(newConfigFile);
45
+ yield fs.mkdir(newConfigParent, { recursive: true });
46
+ yield fs.rename(oldConfigFile, newConfigFile);
47
+ yield fs.rmdir(oldConfigParent);
48
+ }
49
+ catch (err) {
50
+ console.error(err);
51
+ process.exit(1);
52
+ }
53
+ }
54
+ catch (e) {
55
+ // Doing nothing if no old config
56
+ }
57
+ });
58
+ }
59
+ export function apiConfExists() {
60
+ return __awaiter(this, void 0, void 0, function* () {
61
+ try {
62
+ yield fs.access(configPath());
63
+ return true;
64
+ }
65
+ catch (_) {
66
+ return false;
67
+ }
68
+ });
69
+ }
24
70
  export function openApiConf() {
25
71
  return __awaiter(this, void 0, void 0, function* () {
26
72
  let strConf;
@@ -29,9 +75,15 @@ export function openApiConf() {
29
75
  try {
30
76
  strConf = yield fs.readFile(configFile, { encoding: "utf8" });
31
77
  }
32
- catch (err) {
33
- console.error(err, `\n\nUnable to read ${configFile}, run 'dfl login' first.`);
34
- process.exit(1);
78
+ catch (e) {
79
+ try {
80
+ yield moveOldConfig(); // Check for old config folder to rename
81
+ strConf = yield fs.readFile(configFile, { encoding: "utf8" });
82
+ }
83
+ catch (err) {
84
+ console.error(err, `\n\nUnable to read ${configFile}, run 'dxfl login' first.`);
85
+ process.exit(1);
86
+ }
35
87
  }
36
88
  try {
37
89
  dictConf = JSON.parse(strConf);
@@ -79,3 +131,39 @@ export function login(username) {
79
131
  console.log("ok");
80
132
  });
81
133
  }
134
+ export function logout(options) {
135
+ return __awaiter(this, void 0, void 0, function* () {
136
+ const configFile = configPath();
137
+ const parentFolder = path.dirname(configFile);
138
+ // Check if config file exist
139
+ try {
140
+ yield fs.access(configFile);
141
+ }
142
+ catch (err) {
143
+ console.error(err, `\n\nUnable to find ${configFile}. Are you logged on this device?`);
144
+ process.exit(1);
145
+ }
146
+ // If not --yes: ask for confirmation before proceeding
147
+ if (!options.yes) {
148
+ process.stdout.write(`Are you sure to clean dxfl's config folder? (${parentFolder})\n`);
149
+ const ok = yield confirmationPrompt(() => {
150
+ process.stdout.write("Details of planned operations:\n");
151
+ process.stdout.write(` Delete file ${configFile}\n`);
152
+ process.stdout.write(` Delete folder ${parentFolder}\n`);
153
+ });
154
+ if (!ok) {
155
+ return;
156
+ }
157
+ }
158
+ try {
159
+ yield fs.rm(configFile);
160
+ yield fs.rm(parentFolder, { recursive: true });
161
+ process.stdout.write("Config successfully cleaned up!\n");
162
+ process.stdout.write("You can now go outside, feel the sun and touch grass, you are free! ☀️\n");
163
+ }
164
+ catch (err) {
165
+ console.error(err, `\n\nUnable to cleanup config folder. Do you have the permission to delete ${parentFolder}?`);
166
+ process.exit(1);
167
+ }
168
+ });
169
+ }
package/dist/bucket.js CHANGED
@@ -9,36 +9,68 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import fs from "fs";
11
11
  import mime from "mime";
12
+ import { WebsiteApi } from "guichet-sdk-ts";
12
13
  import { DeleteObjectCommand, DeleteObjectsCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
13
14
  import { PromisePool } from "@supercharge/promise-pool";
15
+ import { apiConfExists, openApiConf } from "./auth.js";
14
16
  import { parseEtag, toChunks, formatBytesHuman } from "./utils.js";
15
- export function getBucket(api, vhost) {
17
+ export function getBucketCredentials(name) {
18
+ return __awaiter(this, void 0, void 0, function* () {
19
+ if (yield apiConfExists()) {
20
+ const api = new WebsiteApi(yield openApiConf());
21
+ return yield credentialsFromApi(api, name);
22
+ }
23
+ else {
24
+ const creds = credentialsFromEnv();
25
+ if (creds === undefined) {
26
+ throw new Error("Failed to load credentials.\n" +
27
+ "You need to run 'dxfl login', " +
28
+ "or define the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.");
29
+ }
30
+ return creds;
31
+ }
32
+ });
33
+ }
34
+ function credentialsFromEnv() {
35
+ if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) {
36
+ return {
37
+ key: process.env.AWS_ACCESS_KEY_ID,
38
+ secret: process.env.AWS_SECRET_ACCESS_KEY,
39
+ };
40
+ }
41
+ else {
42
+ return undefined;
43
+ }
44
+ }
45
+ function credentialsFromApi(api, website) {
16
46
  return __awaiter(this, void 0, void 0, function* () {
17
47
  // Get website info from guichet (bucket name and keys)
18
- const bucket = yield api.getWebsite({ vhost }).catch(err => {
48
+ const bucket = yield api.getWebsite({ vhost: website }).catch(err => {
19
49
  var _a;
20
50
  if (((_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.status) == 404) {
21
- console.error(`Error: website '${vhost}' does not exist`);
51
+ console.error(`Error: website '${website}' does not exist`);
22
52
  }
23
53
  else {
24
54
  console.error(err);
25
55
  }
26
56
  throw new Error(err);
27
57
  });
58
+ return { key: bucket.accessKeyId, secret: bucket.secretAccessKey };
59
+ });
60
+ }
61
+ export function getBucket(name, creds) {
62
+ return __awaiter(this, void 0, void 0, function* () {
28
63
  // Initialize a S3 Client
29
64
  const client = new S3Client({
30
65
  endpoint: "https://garage.deuxfleurs.fr",
31
66
  region: "garage",
32
67
  forcePathStyle: true,
33
68
  credentials: {
34
- accessKeyId: bucket.accessKeyId,
35
- secretAccessKey: bucket.secretAccessKey,
69
+ accessKeyId: creds.key,
70
+ secretAccessKey: creds.secret,
36
71
  },
37
72
  });
38
- return {
39
- client: client,
40
- name: bucket.vhost.name,
41
- };
73
+ return { client, name };
42
74
  });
43
75
  }
44
76
  export function getBucketFiles(bucket) {
package/dist/deploy.js CHANGED
@@ -9,12 +9,10 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import fs from "fs";
11
11
  import path from "path";
12
- import { WebsiteApi } from "guichet-sdk-ts";
13
12
  import { PromisePool } from "@supercharge/promise-pool";
14
- import { openApiConf } from "./auth.js";
15
- import { deleteBucketFile, deleteBucketFiles, getBucket, getBucketFiles, putEmptyObjectRedirect, uploadFile, } from "./bucket.js";
13
+ import { deleteBucketFile, deleteBucketFiles, getBucketCredentials, getBucket, getBucketFiles, putEmptyObjectRedirect, uploadFile, } from "./bucket.js";
16
14
  import { confirmationPrompt, filterMap, formatBytesHuman, formatCount, getFileMd5, sum, } from "./utils.js";
17
- import { equalBucketRedirect, getBucketConfig, putBucketWebsiteConfig, readConfigFile, } from "./website_config.js";
15
+ import { equalBucketRedirect, equalCorsRules, getBucketConfig, putBucketWebsiteConfig, putCorsRules, readConfigFile, } from "./website_config.js";
18
16
  // Walks through the local directory at path `dir`, and for each file it contains, returns :
19
17
  // - `localPath`: its path on the local filesystem (includes `dir`). On windows, this path
20
18
  // will typically use `\` as separator.
@@ -134,6 +132,10 @@ function computeDeployPlan(localFiles, remoteFiles, localCfg, remoteCfg) {
134
132
  from: remoteCfg.bucket_redirects,
135
133
  to: localCfg.bucket_redirects,
136
134
  },
135
+ cors_rules: {
136
+ from: remoteCfg.cors_rules,
137
+ to: localCfg.cors_rules,
138
+ },
137
139
  };
138
140
  }
139
141
  function diffBucketRedirects(from, to) {
@@ -163,8 +165,8 @@ function printPlan(plan, details) {
163
165
  const proto = r.protocol ? `${r.protocol}://` : "";
164
166
  const hostname = r.hostname ? `${r.hostname}/` : "";
165
167
  const status = r.status ? ` status=${r.status}` : "";
166
- const if_error = r.if_error ? ` if_error=${r.if_error}` : "";
167
- return `${proto}${hostname}${to}${status}${if_error}`;
168
+ const force = r.force ? ` force=${r.force}` : "";
169
+ return `${proto}${hostname}${to}${status}${force}`;
168
170
  }
169
171
  function showBucketRedirect(r) {
170
172
  return `${r.prefix}* -> ${showBucketRedirectTarget(r)}`;
@@ -184,6 +186,8 @@ function printPlan(plan, details) {
184
186
  const oredirects_added = plan.redirects.filter(r => r.action == "add");
185
187
  const oredirects_updated = plan.redirects.filter(r => r.action == "update");
186
188
  const oredirects_deleted = plan.redirects.filter(r => r.action == "delete");
189
+ // check whether CORS rules changed
190
+ const cors_changed = !equalCorsRules(plan.cors_rules.from, plan.cors_rules.to);
187
191
  // print
188
192
  if (details == "summary") {
189
193
  const sizeRemote = sum([...plan.remoteFiles.values()].map(f => { var _a; return (_a = f.size) !== null && _a !== void 0 ? _a : 0; }));
@@ -211,6 +215,9 @@ function printPlan(plan, details) {
211
215
  if (sentence != "") {
212
216
  process.stdout.write(` ${sentence}\n`);
213
217
  }
218
+ if (cors_changed) {
219
+ process.stdout.write(" CORS rules modified\n");
220
+ }
214
221
  process.stdout.write(` size: ${formatBytesHuman(sizeRemote)} -> ${formatBytesHuman(sizeLocal)}\n`);
215
222
  }
216
223
  else {
@@ -239,6 +246,20 @@ function printPlan(plan, details) {
239
246
  for (const { source, prev_target } of oredirects_deleted) {
240
247
  process.stdout.write(` Delete redirect ${source} -> ${prev_target}\n`);
241
248
  }
249
+ if (cors_changed) {
250
+ process.stdout.write(" Set CORS rules:\n");
251
+ for (const rule of plan.cors_rules.to) {
252
+ function pp(a) {
253
+ return a.length == 1
254
+ ? `"${a[0]}"`
255
+ : `[${a.map(s => `"${s}"`).join(", ")}]`;
256
+ }
257
+ process.stdout.write(` - allowed_origins = ${pp(rule.allowed_origins)}\n`);
258
+ process.stdout.write(` allowed_methods = ${pp(rule.allowed_methods)}\n`);
259
+ process.stdout.write(` allowed_headers = ${pp(rule.allowed_headers)}\n`);
260
+ process.stdout.write(` expose_headers = ${pp(rule.expose_headers)}\n`);
261
+ }
262
+ }
242
263
  }
243
264
  // print other (index, error page)
244
265
  function s(s) {
@@ -289,6 +310,10 @@ function applyDeployPlan(bucket, plan) {
289
310
  }));
290
311
  // Apply bucket redirects & global config
291
312
  yield putBucketWebsiteConfig(bucket, plan.index_page.to, plan.error_page.to, plan.bucket_redirects.to);
313
+ // Apply CORS rules
314
+ if (!equalCorsRules(plan.cors_rules.from, plan.cors_rules.to)) {
315
+ yield putCorsRules(bucket, plan.cors_rules.to);
316
+ }
292
317
  });
293
318
  }
294
319
  function deployMain(website, localFolder, options) {
@@ -308,9 +333,7 @@ function deployMain(website, localFolder, options) {
308
333
  getLocalFilesWithInfo(localFolder),
309
334
  // Get the bucket, list of files stored in the bucket, and bucket website config
310
335
  (() => __awaiter(this, void 0, void 0, function* () {
311
- const guichet = yield openApiConf();
312
- const api = new WebsiteApi(guichet);
313
- const bucket = yield getBucket(api, website);
336
+ const bucket = yield getBucket(website, yield getBucketCredentials(website));
314
337
  const remoteFiles = yield getBucketFiles(bucket);
315
338
  // This can be slow because it needs to query each object in the bucket.
316
339
  const remoteWebsiteConfig = yield getBucketConfig(bucket, [
@@ -360,7 +383,6 @@ export function deploy(website, localFolder, options) {
360
383
  console.error(`Error: ${err}`);
361
384
  }
362
385
  else {
363
- console.error("Error:");
364
386
  console.error(err);
365
387
  }
366
388
  process.exit(1);
package/dist/empty.js CHANGED
@@ -7,9 +7,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { WebsiteApi } from "guichet-sdk-ts";
11
- import { openApiConf } from "./auth.js";
12
- import { getBucket, getBucketFiles, deleteBucketFiles } from "./bucket.js";
10
+ import { getBucket, getBucketCredentials, getBucketFiles, deleteBucketFiles, } from "./bucket.js";
13
11
  import { confirmationPrompt, formatBytesHuman, formatCount, sum, } from "./utils.js";
14
12
  function emptyMain(website, options) {
15
13
  return __awaiter(this, void 0, void 0, function* () {
@@ -17,9 +15,7 @@ function emptyMain(website, options) {
17
15
  console.error("Error: options --yes and --dry-run cannot be passed at the same time");
18
16
  }
19
17
  process.stdout.write("Fetching the website configuration and metadata...\n");
20
- const guichet = yield openApiConf();
21
- const api = new WebsiteApi(guichet);
22
- const bucket = yield getBucket(api, website);
18
+ const bucket = yield getBucket(website, yield getBucketCredentials(website));
23
19
  const filesToDelete = [...(yield getBucketFiles(bucket))].map(([name, { size }]) => ({
24
20
  name,
25
21
  size,
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { program } from "@commander-js/extra-typings";
3
- import { login } from "./auth.js";
3
+ import { login, logout } from "./auth.js";
4
4
  import { deploy } from "./deploy.js";
5
5
  import { empty } from "./empty.js";
6
6
  import { vhostsList } from "./vhosts.js";
7
- program.name("dxfl").description("Deuxfleurs CLI tool").version("0.1.8");
7
+ program.name("dxfl").description("Deuxfleurs CLI tool").version("0.1.10");
8
8
  program
9
9
  .command("login")
10
10
  .description("Link your Deuxfleurs account with this tool.")
@@ -29,4 +29,9 @@ program
29
29
  .option("-n, --dry-run", "do a trial run without making actual changes")
30
30
  .option("-y, --yes", "apply the changes without asking for confirmation")
31
31
  .action(empty);
32
+ program
33
+ .command("logout")
34
+ .description("Cleanup dxfl config containing your credentials")
35
+ .option("-y, --yes", "clean without asking for confirmation")
36
+ .action(logout);
32
37
  program.parse();
@@ -12,7 +12,7 @@ import TOML from "smol-toml";
12
12
  import URI from "fast-uri";
13
13
  import { z as zod } from "zod";
14
14
  import { fromError as zodError } from "zod-validation-error";
15
- import { GetBucketWebsiteCommand, PutBucketWebsiteCommand, } from "@aws-sdk/client-s3";
15
+ import { GetBucketCorsCommand, GetBucketWebsiteCommand, PutBucketWebsiteCommand, PutBucketCorsCommand, } from "@aws-sdk/client-s3";
16
16
  import { getBucketFilesDetails } from "./bucket.js";
17
17
  ////////////// Utilities
18
18
  export function equalBucketRedirect(b1, b2) {
@@ -28,12 +28,27 @@ export function equalBucketRedirect(b1, b2) {
28
28
  }
29
29
  }
30
30
  return (b1.prefix === b2.prefix &&
31
- b1.if_error === b2.if_error &&
31
+ b1.force === b2.force &&
32
32
  b1.hostname === b2.hostname &&
33
33
  b1.status === b2.status &&
34
34
  b1.protocol === b2.protocol &&
35
35
  equalTo(b1.to, b2.to));
36
36
  }
37
+ export function equalCorsRules(c1, c2) {
38
+ function eqArr(eqT, x, y) {
39
+ return x.length === y.length && x.every((v, i) => eqT(y[i], v));
40
+ }
41
+ function eqString(s1, s2) {
42
+ return s1 == s2;
43
+ }
44
+ function eqRule(c1, c2) {
45
+ return (eqArr(eqString, c1.allowed_origins, c2.allowed_origins) &&
46
+ eqArr(eqString, c1.allowed_headers, c2.allowed_headers) &&
47
+ eqArr(eqString, c1.allowed_methods, c2.allowed_methods) &&
48
+ eqArr(eqString, c1.expose_headers, c2.expose_headers));
49
+ }
50
+ return eqArr(eqRule, c1, c2);
51
+ }
37
52
  ////////////// Parsing from a TOML config file
38
53
  // Parsing: TOML -> untyped object
39
54
  function readConfigFileObject(filename) {
@@ -64,13 +79,20 @@ function readConfigFileObject(filename) {
64
79
  const RawRedirectSchema = zod.object({
65
80
  from: zod.string(),
66
81
  to: zod.string(),
67
- if_error: zod.number().int().optional(),
82
+ force: zod.boolean().optional(),
68
83
  status: zod.number().int().positive().optional(),
69
84
  });
85
+ const RawCorsSchema = zod.object({
86
+ allowed_origins: zod.union([zod.string(), zod.string().array()]),
87
+ allowed_methods: zod.union([zod.string(), zod.string().array()]).optional(),
88
+ allowed_headers: zod.union([zod.string(), zod.string().array()]).optional(),
89
+ expose_headers: zod.union([zod.string(), zod.string().array()]).optional(),
90
+ });
70
91
  const RawConfigSchema = zod.object({
71
92
  index_page: zod.string().optional(),
72
93
  error_page: zod.string().optional(),
73
94
  redirects: zod.array(RawRedirectSchema).optional(),
95
+ cors: zod.array(RawCorsSchema).optional(),
74
96
  });
75
97
  function interpRawConfig(cfg) {
76
98
  try {
@@ -114,7 +136,7 @@ function unescape(s) {
114
136
  }
115
137
  }
116
138
  function interpConfig(rawcfg) {
117
- var _a, _b;
139
+ var _a, _b, _c, _d, _e;
118
140
  function interpPath(name, str) {
119
141
  try {
120
142
  return unescape(str);
@@ -124,7 +146,7 @@ function interpConfig(rawcfg) {
124
146
  }
125
147
  }
126
148
  function interpRedirect(i, r) {
127
- var _a;
149
+ var _a, _b;
128
150
  const rfrom = interpPath(`Redirect ${i}, from`, r.from);
129
151
  const rto = interpPath(`Redirect ${i}, to`, r.to);
130
152
  let redirect;
@@ -167,7 +189,7 @@ function interpConfig(rawcfg) {
167
189
  }
168
190
  let bredirect = {
169
191
  prefix,
170
- if_error: undefined,
192
+ force: (_b = r.force) !== null && _b !== void 0 ? _b : false,
171
193
  hostname: toURI.host,
172
194
  protocol: toURI.scheme,
173
195
  // Unless specified, set the status to 302.
@@ -180,12 +202,6 @@ function interpConfig(rawcfg) {
180
202
  ? { kind: "replace_prefix", prefix: path }
181
203
  : { kind: "replace", target: path },
182
204
  };
183
- if (r.if_error) {
184
- if (r.if_error != 404) {
185
- throw `Redirect ${i}: the only currently supported value for 'if_error' is 404`;
186
- }
187
- bredirect.if_error = 404;
188
- }
189
205
  if (r.status) {
190
206
  if ([301, 302, 303, 307, 308].includes(r.status)) {
191
207
  bredirect.status = r.status;
@@ -204,8 +220,9 @@ function interpConfig(rawcfg) {
204
220
  }
205
221
  else {
206
222
  // This is an object redirect
207
- if (r.if_error) {
208
- throw `Redirect ${i}: 'if_error' is not supported for single redirections`;
223
+ if (r.force) {
224
+ throw (`Redirect ${i}: 'force' is not supported for single redirections.\n` +
225
+ "The source of the redirection must not exist already as a file.");
209
226
  }
210
227
  if (r.status) {
211
228
  throw (`Redirect ${i}: 'status' is not supported for single redirections.\n` +
@@ -221,7 +238,7 @@ function interpConfig(rawcfg) {
221
238
  if (from.startsWith("/")) {
222
239
  from = from.substring(1);
223
240
  }
224
- // for the 'to' field, garage expects a target that starts with 'http://', 'https://'
241
+ // for the 'to' field, garage expects the target to start with 'http://', 'https://'
225
242
  // or '/'. So if the 'to' field does not start with '/', add one...
226
243
  let to = rto.s;
227
244
  if (!(to.startsWith("http://") ||
@@ -233,14 +250,29 @@ function interpConfig(rawcfg) {
233
250
  }
234
251
  return redirect;
235
252
  }
253
+ // Default handling for an optional string:
254
+ // undefined -> def
255
+ // "" -> undefined
256
+ // other -> other.
257
+ //
258
+ // `""` is "disable this", while `undefined` is "use the default".
259
+ // This works as long as the empty string is not a valid value for `s`.
260
+ let withDefault = (s, def) => {
261
+ if (s === "") {
262
+ return undefined;
263
+ }
264
+ return s !== null && s !== void 0 ? s : def;
265
+ };
236
266
  let cfg = {
237
267
  // use index.html as default index if not specified explicitly
238
- index_page: (_a = rawcfg.index_page) !== null && _a !== void 0 ? _a : "index.html",
239
- error_page: rawcfg.error_page,
268
+ index_page: withDefault(rawcfg.index_page, "index.html"),
269
+ // use error.html as default error page
270
+ error_page: withDefault(rawcfg.error_page, "error.html"),
240
271
  bucket_redirects: [],
241
272
  object_redirects: new Map(),
273
+ cors_rules: [],
242
274
  };
243
- for (const [i, raw] of ((_b = rawcfg.redirects) !== null && _b !== void 0 ? _b : []).entries()) {
275
+ for (const [i, raw] of ((_a = rawcfg.redirects) !== null && _a !== void 0 ? _a : []).entries()) {
244
276
  // `i+1` is only used for display: start counting redirects from 1 instead of 0
245
277
  const r = interpRedirect(i + 1, raw);
246
278
  if (r.kind == "bucket") {
@@ -253,6 +285,17 @@ function interpConfig(rawcfg) {
253
285
  cfg.object_redirects.set(r.from, r.to);
254
286
  }
255
287
  }
288
+ for (const [_, raw] of ((_b = rawcfg.cors) !== null && _b !== void 0 ? _b : []).entries()) {
289
+ const interpRawArray = (x) => {
290
+ return typeof x === "string" ? [x] : x;
291
+ };
292
+ cfg.cors_rules.push({
293
+ allowed_origins: interpRawArray(raw.allowed_origins),
294
+ allowed_methods: interpRawArray((_c = raw.allowed_methods) !== null && _c !== void 0 ? _c : ["GET", "HEAD"]),
295
+ allowed_headers: interpRawArray((_d = raw.allowed_methods) !== null && _d !== void 0 ? _d : []),
296
+ expose_headers: interpRawArray((_e = raw.expose_headers) !== null && _e !== void 0 ? _e : []),
297
+ });
298
+ }
256
299
  return cfg;
257
300
  }
258
301
  export function readConfigFile(filename) {
@@ -265,16 +308,27 @@ export function getBucketConfig(bucket, files) {
265
308
  return __awaiter(this, void 0, void 0, function* () {
266
309
  // This function performs the following S3 requests:
267
310
  // - GetBucketWebsite to get bucket-level redirects;
311
+ // - GetBucketCors to get the bucket CORS configuration;
268
312
  // - A Head command for each file in the bucket to collect object redirects.
269
313
  // (This can become relatively slow for buckets with thousands of files,
270
314
  // but I don't know of a better way.)
271
- var _a, _b;
272
- const [response, details] = yield Promise.all([
315
+ var _a, _b, _c, _d, _e, _f;
316
+ const [website, cors, details] = yield Promise.all([
273
317
  bucket.client.send(new GetBucketWebsiteCommand({ Bucket: bucket.name })),
318
+ bucket.client.send(new GetBucketCorsCommand({ Bucket: bucket.name })),
274
319
  getBucketFilesDetails(bucket, files),
275
320
  ]);
276
- if (response && response.$metadata.httpStatusCode != 200) {
277
- throw `Error sending GetBucketWebsite: ${response}`;
321
+ if (website && website.$metadata.httpStatusCode != 200) {
322
+ throw `Error sending GetBucketWebsite: ${JSON.stringify(website)}`;
323
+ }
324
+ if (!cors ||
325
+ (cors.$metadata.httpStatusCode != 200 &&
326
+ cors.$metadata.httpStatusCode != 204)) {
327
+ throw `Error sending GetBucketCors: ${JSON.stringify(cors)}`;
328
+ }
329
+ // 204 "No Content" is returned when there are no existing CORS rules
330
+ if (cors.$metadata.httpStatusCode == 204) {
331
+ cors.CORSRules = [];
278
332
  }
279
333
  // Collect object redirects
280
334
  const object_redirects = new Map();
@@ -284,29 +338,30 @@ export function getBucketConfig(bucket, files) {
284
338
  }
285
339
  }
286
340
  // Interpret bucket redirects
287
- if (response.RedirectAllRequestsTo) {
341
+ if (website.RedirectAllRequestsTo) {
288
342
  // NB: garage does not currently support RedirectAllRequestsTo so this should never happen
289
343
  throw (`remote website configuration: RedirectAllRequestsTo is specified; ` +
290
344
  `this is currently unsupported by dxfl`);
291
345
  }
292
- const index_page = (_a = response.IndexDocument) === null || _a === void 0 ? void 0 : _a.Suffix;
293
- const error_page = (_b = response.ErrorDocument) === null || _b === void 0 ? void 0 : _b.Key;
346
+ const index_page = (_a = website.IndexDocument) === null || _a === void 0 ? void 0 : _a.Suffix;
347
+ const error_page = (_b = website.ErrorDocument) === null || _b === void 0 ? void 0 : _b.Key;
294
348
  let bucket_redirects = [];
295
- if (response.RoutingRules) {
296
- for (const rule of response.RoutingRules) {
349
+ if (website.RoutingRules) {
350
+ for (const rule of website.RoutingRules) {
297
351
  // If no Condition is specified, then the redirect always applies, which is equivalent
298
352
  // to matching on an empty prefix
299
353
  let prefix = "";
300
- let if_error = undefined;
354
+ // If no HttpErrorCodeReturnedEquals is specified, then the redirection always
355
+ // applies, which is equivalent to force=true
356
+ let force = true;
301
357
  if (rule.Condition) {
302
358
  if (rule.Condition.HttpErrorCodeReturnedEquals) {
303
359
  if (rule.Condition.HttpErrorCodeReturnedEquals == "404") {
304
- if_error = 404;
360
+ force = false;
305
361
  }
306
362
  else {
307
- // currently not supported by garage
308
- throw (`remote website configuration: 'if_error' specified with a different ` +
309
- `status code than 404; this is currently unsupported by dxfl`);
363
+ // not supported by garage
364
+ throw `remote website configuration: unexpected value for 'HttpErrorCodeReturnedEquals': ${rule.Condition.HttpErrorCodeReturnedEquals}`;
310
365
  }
311
366
  }
312
367
  if (rule.Condition.KeyPrefixEquals) {
@@ -346,7 +401,7 @@ export function getBucketConfig(bucket, files) {
346
401
  }
347
402
  bucket_redirects.push({
348
403
  prefix,
349
- if_error,
404
+ force,
350
405
  hostname,
351
406
  status,
352
407
  protocol,
@@ -354,7 +409,25 @@ export function getBucketConfig(bucket, files) {
354
409
  });
355
410
  }
356
411
  }
357
- return { index_page, error_page, bucket_redirects, object_redirects };
412
+ // Interpret CORS rules
413
+ let cors_rules = [];
414
+ if (cors.CORSRules) {
415
+ for (const rule of cors.CORSRules) {
416
+ cors_rules.push({
417
+ allowed_origins: (_c = rule.AllowedOrigins) !== null && _c !== void 0 ? _c : [],
418
+ allowed_methods: (_d = rule.AllowedMethods) !== null && _d !== void 0 ? _d : [],
419
+ allowed_headers: (_e = rule.AllowedHeaders) !== null && _e !== void 0 ? _e : [],
420
+ expose_headers: (_f = rule.ExposeHeaders) !== null && _f !== void 0 ? _f : [],
421
+ });
422
+ }
423
+ }
424
+ return {
425
+ index_page,
426
+ error_page,
427
+ bucket_redirects,
428
+ object_redirects,
429
+ cors_rules,
430
+ };
358
431
  });
359
432
  }
360
433
  ////////////// Applying a configuration to S3
@@ -378,8 +451,8 @@ export function putBucketWebsiteConfig(bucket, index_page, error_page, bucket_re
378
451
  },
379
452
  Redirect: {},
380
453
  };
381
- if (r.if_error) {
382
- rule.Condition.HttpErrorCodeReturnedEquals = r.if_error.toString();
454
+ if (!r.force) {
455
+ rule.Condition.HttpErrorCodeReturnedEquals = "404";
383
456
  }
384
457
  if (r.hostname) {
385
458
  rule.Redirect.HostName = r.hostname;
@@ -408,3 +481,24 @@ export function putBucketWebsiteConfig(bucket, index_page, error_page, bucket_re
408
481
  }
409
482
  });
410
483
  }
484
+ // applies CORS rules
485
+ export function putCorsRules(bucket, cors_rules) {
486
+ return __awaiter(this, void 0, void 0, function* () {
487
+ let CORSRules = [];
488
+ for (const rule of cors_rules) {
489
+ CORSRules.push({
490
+ AllowedOrigins: rule.allowed_origins,
491
+ AllowedMethods: rule.allowed_methods,
492
+ AllowedHeaders: rule.allowed_headers,
493
+ ExposeHeaders: rule.expose_headers,
494
+ });
495
+ }
496
+ const resp = yield bucket.client.send(new PutBucketCorsCommand({
497
+ Bucket: bucket.name,
498
+ CORSConfiguration: { CORSRules },
499
+ }));
500
+ if (resp && resp.$metadata.httpStatusCode != 200) {
501
+ throw resp;
502
+ }
503
+ });
504
+ }
@@ -0,0 +1,19 @@
1
+ name: Deploy with GitHub Actions
2
+
3
+ # You will need to:
4
+ # 1. Get (from Guichet web interface):
5
+ # - Your access keys (ID and secret): in the "S3" tab of your website
6
+ # - The website id: your sub-domain (the part before .web.deuxfleurs.fr) or your full custom domain (e.g. your-domain.com)
7
+ # 2. Setup the access keys as secrets in GitHub web interface (https://docs.github.com/en/actions/how-tos/writing-workflows/choosing-what-your-workflow-does/using-secrets-in-github-actions), named in this example key_id and key_secret
8
+ # 3. If you use a deuxfleurs.toml config, be sure the dxfl deploy command is launch in the current directory its in.
9
+
10
+ jobs:
11
+ build:
12
+ # Build your website in a dedicated step to avoid to expose your access keys to external software
13
+ deploy:
14
+ runs-on: node:lts-alpine
15
+ steps:
16
+ - run: npx --yes dxfl deploy your-website-id build/ --yes
17
+ env:
18
+ AWS_ACCESS_KEY_ID: ${{ secrets.key_id }}
19
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.key_secret }}
@@ -0,0 +1,21 @@
1
+ ## Deploy with Woodpecker CI
2
+ #
3
+ # You will need to:
4
+ # 1. Get (from Guichet web interface):
5
+ # - Your access keys (ID and secret): in the "S3" tab of your website
6
+ # - The website id: your sub-domain (the part before .web.deuxfleurs.fr) or your full custom domain (e.g. your-domain.com)
7
+ # 2. Setup the access keys as secrets in Woodpecker web interface (https://woodpecker-ci.org/docs/usage/secrets), named in this example key_id and key_secret
8
+ # 3. If you use a deuxfleurs.toml config, be sure the dxfl deploy command is launch in the current directory its in.
9
+
10
+ steps:
11
+ - name: build
12
+ # Build your website in a dedicated step to avoid to expose your access keys to external software
13
+ - name: deploy
14
+ image: node:lts-alpine
15
+ environment:
16
+ AWS_ACCESS_KEY_ID:
17
+ from_secret: key_id
18
+ AWS_SECRET_ACCESS_KEY:
19
+ from_secret: key_secret
20
+ commands:
21
+ - npx --yes dxfl deploy your-website-id build/ --yes
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "dxfl",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Deuxfleurs Team <coucou@deuxfleurs.fr>",
7
+ "keywords": [
8
+ "cli",
9
+ "deuxfleurs"
10
+ ],
7
11
  "type": "module",
8
12
  "main": "dist/main.js",
9
13
  "bin": {