dxfl 0.1.9 → 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 +8 -0
- package/README.md +54 -4
- package/dist/auth.js +94 -6
- package/dist/bucket.js +41 -9
- package/dist/deploy.js +30 -8
- package/dist/empty.js +2 -6
- package/dist/index.js +7 -2
- package/dist/website_config.js +113 -15
- package/doc/workflows/gh-actions.yml +19 -0
- package/doc/workflows/woodpecker.yml +21 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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
|
+
|
|
1
9
|
# v0.1.9
|
|
2
10
|
|
|
3
11
|
- `deuxfleurs.toml`: rename redirection property `if_error = 404` to `force = true`, to simplify and improve comprehension.
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install -g dxfl
|
|
|
12
12
|
|
|
13
13
|
_Warning: software still in an experimental state_
|
|
14
14
|
|
|
15
|
-
Start by
|
|
15
|
+
Start by logging in with your username, for example for `john`:
|
|
16
16
|
|
|
17
17
|
```
|
|
18
18
|
dxfl login john
|
|
@@ -60,8 +60,8 @@ Your `deuxfleurs.toml` should follow the following structure:
|
|
|
60
60
|
# Default value: "index.html"
|
|
61
61
|
index_page = "index.html"
|
|
62
62
|
# Path to the file to serve in case of 404 error.
|
|
63
|
-
# Default value:
|
|
64
|
-
error_page = "
|
|
63
|
+
# Default value: "error.html"
|
|
64
|
+
error_page = "error.html"
|
|
65
65
|
|
|
66
66
|
# A redirect entry. There can be as many as desired.
|
|
67
67
|
[[redirects]]
|
|
@@ -90,7 +90,57 @@ status = 301
|
|
|
90
90
|
# applies the redirection.
|
|
91
91
|
# Default value: false
|
|
92
92
|
force = true
|
|
93
|
-
|
|
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.
|
|
94
144
|
|
|
95
145
|
## Development
|
|
96
146
|
|
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 = ".
|
|
16
|
+
let path = ".dxfl/config.json";
|
|
16
17
|
if (process.env.XDG_CONFIG_HOME) {
|
|
17
|
-
path = process.env.XDG_CONFIG_HOME + "/
|
|
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/
|
|
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 (
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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 '${
|
|
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:
|
|
35
|
-
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 {
|
|
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) {
|
|
@@ -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
|
|
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 {
|
|
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
|
|
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.
|
|
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();
|
package/dist/website_config.js
CHANGED
|
@@ -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) {
|
|
@@ -34,6 +34,21 @@ export function equalBucketRedirect(b1, b2) {
|
|
|
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) {
|
|
@@ -67,10 +82,17 @@ const RawRedirectSchema = zod.object({
|
|
|
67
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);
|
|
@@ -228,14 +250,29 @@ function interpConfig(rawcfg) {
|
|
|
228
250
|
}
|
|
229
251
|
return redirect;
|
|
230
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
|
+
};
|
|
231
266
|
let cfg = {
|
|
232
267
|
// use index.html as default index if not specified explicitly
|
|
233
|
-
index_page: (
|
|
234
|
-
|
|
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"),
|
|
235
271
|
bucket_redirects: [],
|
|
236
272
|
object_redirects: new Map(),
|
|
273
|
+
cors_rules: [],
|
|
237
274
|
};
|
|
238
|
-
for (const [i, raw] of ((
|
|
275
|
+
for (const [i, raw] of ((_a = rawcfg.redirects) !== null && _a !== void 0 ? _a : []).entries()) {
|
|
239
276
|
// `i+1` is only used for display: start counting redirects from 1 instead of 0
|
|
240
277
|
const r = interpRedirect(i + 1, raw);
|
|
241
278
|
if (r.kind == "bucket") {
|
|
@@ -248,6 +285,17 @@ function interpConfig(rawcfg) {
|
|
|
248
285
|
cfg.object_redirects.set(r.from, r.to);
|
|
249
286
|
}
|
|
250
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
|
+
}
|
|
251
299
|
return cfg;
|
|
252
300
|
}
|
|
253
301
|
export function readConfigFile(filename) {
|
|
@@ -260,16 +308,27 @@ export function getBucketConfig(bucket, files) {
|
|
|
260
308
|
return __awaiter(this, void 0, void 0, function* () {
|
|
261
309
|
// This function performs the following S3 requests:
|
|
262
310
|
// - GetBucketWebsite to get bucket-level redirects;
|
|
311
|
+
// - GetBucketCors to get the bucket CORS configuration;
|
|
263
312
|
// - A Head command for each file in the bucket to collect object redirects.
|
|
264
313
|
// (This can become relatively slow for buckets with thousands of files,
|
|
265
314
|
// but I don't know of a better way.)
|
|
266
|
-
var _a, _b;
|
|
267
|
-
const [
|
|
315
|
+
var _a, _b, _c, _d, _e, _f;
|
|
316
|
+
const [website, cors, details] = yield Promise.all([
|
|
268
317
|
bucket.client.send(new GetBucketWebsiteCommand({ Bucket: bucket.name })),
|
|
318
|
+
bucket.client.send(new GetBucketCorsCommand({ Bucket: bucket.name })),
|
|
269
319
|
getBucketFilesDetails(bucket, files),
|
|
270
320
|
]);
|
|
271
|
-
if (
|
|
272
|
-
throw `Error sending GetBucketWebsite: ${
|
|
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 = [];
|
|
273
332
|
}
|
|
274
333
|
// Collect object redirects
|
|
275
334
|
const object_redirects = new Map();
|
|
@@ -279,16 +338,16 @@ export function getBucketConfig(bucket, files) {
|
|
|
279
338
|
}
|
|
280
339
|
}
|
|
281
340
|
// Interpret bucket redirects
|
|
282
|
-
if (
|
|
341
|
+
if (website.RedirectAllRequestsTo) {
|
|
283
342
|
// NB: garage does not currently support RedirectAllRequestsTo so this should never happen
|
|
284
343
|
throw (`remote website configuration: RedirectAllRequestsTo is specified; ` +
|
|
285
344
|
`this is currently unsupported by dxfl`);
|
|
286
345
|
}
|
|
287
|
-
const index_page = (_a =
|
|
288
|
-
const error_page = (_b =
|
|
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;
|
|
289
348
|
let bucket_redirects = [];
|
|
290
|
-
if (
|
|
291
|
-
for (const rule of
|
|
349
|
+
if (website.RoutingRules) {
|
|
350
|
+
for (const rule of website.RoutingRules) {
|
|
292
351
|
// If no Condition is specified, then the redirect always applies, which is equivalent
|
|
293
352
|
// to matching on an empty prefix
|
|
294
353
|
let prefix = "";
|
|
@@ -350,7 +409,25 @@ export function getBucketConfig(bucket, files) {
|
|
|
350
409
|
});
|
|
351
410
|
}
|
|
352
411
|
}
|
|
353
|
-
|
|
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
|
+
};
|
|
354
431
|
});
|
|
355
432
|
}
|
|
356
433
|
////////////// Applying a configuration to S3
|
|
@@ -404,3 +481,24 @@ export function putBucketWebsiteConfig(bucket, index_page, error_page, bucket_re
|
|
|
404
481
|
}
|
|
405
482
|
});
|
|
406
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
|