dxfl 0.1.7 → 0.1.8
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/.editorconfig +22 -0
- package/.prettierignore +3 -0
- package/.prettierrc.json +3 -0
- package/CHANGELOG.md +48 -0
- package/README.md +64 -1
- package/dist/_empty.js +64 -0
- package/dist/auth.js +6 -6
- package/dist/bucket.js +174 -0
- package/dist/config.js +185 -0
- package/dist/deploy.js +300 -241
- package/dist/empty.js +73 -0
- package/dist/index.js +23 -14
- package/dist/utils.js +113 -0
- package/dist/website_config.js +410 -0
- package/package.json +15 -4
- package/tsconfig.json +13 -14
package/dist/empty.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { WebsiteApi } from "guichet-sdk-ts";
|
|
11
|
+
import { openApiConf } from "./auth.js";
|
|
12
|
+
import { getBucket, getBucketFiles, deleteBucketFiles } from "./bucket.js";
|
|
13
|
+
import { confirmationPrompt, formatBytesHuman, formatCount, sum, } from "./utils.js";
|
|
14
|
+
function emptyMain(website, options) {
|
|
15
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
16
|
+
if (options.dryRun && options.yes) {
|
|
17
|
+
console.error("Error: options --yes and --dry-run cannot be passed at the same time");
|
|
18
|
+
}
|
|
19
|
+
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);
|
|
23
|
+
const filesToDelete = [...(yield getBucketFiles(bucket))].map(([name, { size }]) => ({
|
|
24
|
+
name,
|
|
25
|
+
size,
|
|
26
|
+
}));
|
|
27
|
+
const sizeDeleted = sum(filesToDelete.map(f => { var _a; return (_a = f.size) !== null && _a !== void 0 ? _a : 0; }));
|
|
28
|
+
function formatFile(file) {
|
|
29
|
+
`${file.name} (${file.size ? formatBytesHuman(file.size) : "?B"})`;
|
|
30
|
+
}
|
|
31
|
+
// If --dry-run: display the changes list and summary and return
|
|
32
|
+
if (options.dryRun) {
|
|
33
|
+
for (const file of filesToDelete) {
|
|
34
|
+
process.stdout.write(`Delete ${formatFile(file)}\n`);
|
|
35
|
+
}
|
|
36
|
+
process.stdout.write("\nSummary:\n");
|
|
37
|
+
process.stdout.write(`${formatCount(filesToDelete.length, "file")} deleted (${formatBytesHuman(sizeDeleted)})\n`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// If not --yes: display the summary, ask for confirmation before proceeding
|
|
41
|
+
if (!options.yes) {
|
|
42
|
+
process.stdout.write(`${formatCount(filesToDelete.length, "file")} (${formatBytesHuman(sizeDeleted)}) are going to be deleted:\n`);
|
|
43
|
+
const ok = yield confirmationPrompt(() => {
|
|
44
|
+
process.stdout.write("Files to delete:\n");
|
|
45
|
+
for (const file of filesToDelete) {
|
|
46
|
+
process.stdout.write(` ${formatFile(file)}\n`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
if (!ok) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// If confirmed
|
|
54
|
+
yield deleteBucketFiles(bucket, filesToDelete);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export function empty(website, options) {
|
|
58
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
59
|
+
try {
|
|
60
|
+
yield emptyMain(website, options);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (typeof err == "string" || (err === null || err === void 0 ? void 0 : err.name) === "AbortError") {
|
|
64
|
+
console.error(`Error: ${err}`);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
console.error("Error:");
|
|
68
|
+
console.error(err);
|
|
69
|
+
}
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { program } from "commander";
|
|
2
|
+
import { program } from "@commander-js/extra-typings";
|
|
3
3
|
import { login } from "./auth.js";
|
|
4
4
|
import { deploy } from "./deploy.js";
|
|
5
|
+
import { empty } from "./empty.js";
|
|
5
6
|
import { vhostsList } from "./vhosts.js";
|
|
7
|
+
program.name("dxfl").description("Deuxfleurs CLI tool").version("0.1.8");
|
|
6
8
|
program
|
|
7
|
-
.
|
|
8
|
-
.description(
|
|
9
|
-
.
|
|
10
|
-
program.command('login')
|
|
11
|
-
.description('Link your Deuxfleurs account with this tool.')
|
|
12
|
-
.argument('<username>', 'your account username')
|
|
9
|
+
.command("login")
|
|
10
|
+
.description("Link your Deuxfleurs account with this tool.")
|
|
11
|
+
.argument("<username>", "your account username")
|
|
13
12
|
.action(login);
|
|
14
|
-
program
|
|
15
|
-
.
|
|
13
|
+
program
|
|
14
|
+
.command("list")
|
|
15
|
+
.description("List all your websites")
|
|
16
16
|
.action(vhostsList);
|
|
17
|
-
program
|
|
18
|
-
.
|
|
19
|
-
.
|
|
20
|
-
.argument(
|
|
21
|
-
.
|
|
17
|
+
program
|
|
18
|
+
.command("deploy")
|
|
19
|
+
.description("Deploy your website")
|
|
20
|
+
.argument("<website>", "selected website")
|
|
21
|
+
.argument("<local_folder>", "your local folder")
|
|
22
|
+
.option("-n, --dry-run", "do a trial run without making actual changes")
|
|
23
|
+
.option("-y, --yes", "apply the changes without asking for confirmation")
|
|
22
24
|
.action(deploy);
|
|
25
|
+
program
|
|
26
|
+
.command("empty")
|
|
27
|
+
.description("Empty your website from its contents")
|
|
28
|
+
.argument("<website>", "selected website")
|
|
29
|
+
.option("-n, --dry-run", "do a trial run without making actual changes")
|
|
30
|
+
.option("-y, --yes", "apply the changes without asking for confirmation")
|
|
31
|
+
.action(empty);
|
|
23
32
|
program.parse();
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
var __asyncValues = (this && this.__asyncValues) || function (o) {
|
|
11
|
+
if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
|
|
12
|
+
var m = o[Symbol.asyncIterator], i;
|
|
13
|
+
return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
|
|
14
|
+
function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
|
|
15
|
+
function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
|
|
16
|
+
};
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
import crypto from "crypto";
|
|
19
|
+
import { stdin, stdout } from "process";
|
|
20
|
+
import readline from "readline/promises";
|
|
21
|
+
export function getFileMd5(file) {
|
|
22
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
23
|
+
var _a, e_1, _b, _c;
|
|
24
|
+
const hash = crypto.createHash("md5");
|
|
25
|
+
try {
|
|
26
|
+
for (var _d = true, _e = __asyncValues(fs.createReadStream(file)), _f; _f = yield _e.next(), _a = _f.done, !_a; _d = true) {
|
|
27
|
+
_c = _f.value;
|
|
28
|
+
_d = false;
|
|
29
|
+
const chunk = _c;
|
|
30
|
+
hash.update(chunk);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (e_1_1) { e_1 = { error: e_1_1 }; }
|
|
34
|
+
finally {
|
|
35
|
+
try {
|
|
36
|
+
if (!_d && !_a && (_b = _e.return)) yield _b.call(_e);
|
|
37
|
+
}
|
|
38
|
+
finally { if (e_1) throw e_1.error; }
|
|
39
|
+
}
|
|
40
|
+
return hash.digest("hex");
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function formatBytesHuman(bytes) {
|
|
44
|
+
if (bytes < 1000) {
|
|
45
|
+
return `${bytes}B`;
|
|
46
|
+
}
|
|
47
|
+
bytes /= 1000;
|
|
48
|
+
if (bytes < 1000) {
|
|
49
|
+
return `${bytes.toFixed(2)}KB`;
|
|
50
|
+
}
|
|
51
|
+
bytes /= 1000;
|
|
52
|
+
if (bytes < 1000) {
|
|
53
|
+
return `${bytes.toFixed(2)}MB`;
|
|
54
|
+
}
|
|
55
|
+
bytes /= 1000;
|
|
56
|
+
return `${bytes.toFixed(2)}GB`;
|
|
57
|
+
}
|
|
58
|
+
export function toChunks(a, chunkSize) {
|
|
59
|
+
let chunks = [];
|
|
60
|
+
let i = 0;
|
|
61
|
+
while (i < a.length) {
|
|
62
|
+
chunks.push(a.slice(i, i + chunkSize));
|
|
63
|
+
i = i + chunkSize;
|
|
64
|
+
}
|
|
65
|
+
return chunks;
|
|
66
|
+
}
|
|
67
|
+
export function sum(a) {
|
|
68
|
+
return a.reduce((x, y) => x + y, 0);
|
|
69
|
+
}
|
|
70
|
+
export function formatCount(nb, thing) {
|
|
71
|
+
if (nb == 1) {
|
|
72
|
+
return `${nb} ${thing}`;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
return `${nb} ${thing}s`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function parseEtag(s) {
|
|
79
|
+
if (s.startsWith('"') && s.endsWith('"')) {
|
|
80
|
+
return s.slice(1, s.length - 1);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function confirmationPrompt(details) {
|
|
87
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
89
|
+
let ok = true;
|
|
90
|
+
while (true) {
|
|
91
|
+
const a = yield rl.question(`Proceed? y (yes), n (no), d (details): `);
|
|
92
|
+
if (a == "y" || a == "yes") {
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
else if (a == "n" || a == "no") {
|
|
96
|
+
ok = false;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
else if (a == "d" || a == "details") {
|
|
100
|
+
details();
|
|
101
|
+
process.stdout.write("\n");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
rl.close();
|
|
105
|
+
return ok;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export function filterMap(a, f) {
|
|
109
|
+
return a.flatMap(x => {
|
|
110
|
+
let y = f(x);
|
|
111
|
+
return y ? [y] : [];
|
|
112
|
+
});
|
|
113
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import fs from "fs";
|
|
11
|
+
import TOML from "smol-toml";
|
|
12
|
+
import URI from "fast-uri";
|
|
13
|
+
import { z as zod } from "zod";
|
|
14
|
+
import { fromError as zodError } from "zod-validation-error";
|
|
15
|
+
import { GetBucketWebsiteCommand, PutBucketWebsiteCommand, } from "@aws-sdk/client-s3";
|
|
16
|
+
import { getBucketFilesDetails } from "./bucket.js";
|
|
17
|
+
////////////// Utilities
|
|
18
|
+
export function equalBucketRedirect(b1, b2) {
|
|
19
|
+
function equalTo(x, y) {
|
|
20
|
+
if (x.kind == "replace_prefix" && y.kind == "replace_prefix") {
|
|
21
|
+
return x.prefix === y.prefix;
|
|
22
|
+
}
|
|
23
|
+
else if (x.kind == "replace" && y.kind == "replace") {
|
|
24
|
+
return x.target === y.target;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return (b1.prefix === b2.prefix &&
|
|
31
|
+
b1.if_error === b2.if_error &&
|
|
32
|
+
b1.hostname === b2.hostname &&
|
|
33
|
+
b1.status === b2.status &&
|
|
34
|
+
b1.protocol === b2.protocol &&
|
|
35
|
+
equalTo(b1.to, b2.to));
|
|
36
|
+
}
|
|
37
|
+
////////////// Parsing from a TOML config file
|
|
38
|
+
// Parsing: TOML -> untyped object
|
|
39
|
+
function readConfigFileObject(filename) {
|
|
40
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
41
|
+
if (!fs.existsSync(filename)) {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
let strConf;
|
|
45
|
+
let dictConf;
|
|
46
|
+
try {
|
|
47
|
+
strConf = yield fs.promises.readFile(filename, { encoding: "utf8" });
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
console.error(err, `\n\nUnable to read ${filename}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
dictConf = TOML.parse(strConf);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
console.error(err, `\n\nUnable to parse ${filename} as TOML, please check it for syntax errors.`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
return dictConf;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// Parsing: untyped object -> RawConfig
|
|
64
|
+
const RawRedirectSchema = zod.object({
|
|
65
|
+
from: zod.string(),
|
|
66
|
+
to: zod.string(),
|
|
67
|
+
if_error: zod.number().int().optional(),
|
|
68
|
+
status: zod.number().int().positive().optional(),
|
|
69
|
+
});
|
|
70
|
+
const RawConfigSchema = zod.object({
|
|
71
|
+
index_page: zod.string().optional(),
|
|
72
|
+
error_page: zod.string().optional(),
|
|
73
|
+
redirects: zod.array(RawRedirectSchema).optional(),
|
|
74
|
+
});
|
|
75
|
+
function interpRawConfig(cfg) {
|
|
76
|
+
try {
|
|
77
|
+
return RawConfigSchema.parse(cfg);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const validationError = zodError(err);
|
|
81
|
+
throw validationError.toString();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Parsing: RawConfig -> WebsiteConfig
|
|
85
|
+
// Parses the escaping scheme used for 'from' and 'to' paths
|
|
86
|
+
// in redirects. These fields may specify a '*' at the end
|
|
87
|
+
// (in that case they indicate a prefix) or not (then they are
|
|
88
|
+
// absolute URLs.
|
|
89
|
+
// Occurences of '*' that are part of the path itself must be
|
|
90
|
+
// doubled as '**' in order to avoid any ambiguity.
|
|
91
|
+
function unescape(s) {
|
|
92
|
+
let out = "";
|
|
93
|
+
const iter = s[Symbol.iterator]();
|
|
94
|
+
while (true) {
|
|
95
|
+
const elt = iter.next();
|
|
96
|
+
if (elt.done == true) {
|
|
97
|
+
return { s: out, is_prefix: false };
|
|
98
|
+
}
|
|
99
|
+
if (elt.value == "*") {
|
|
100
|
+
const next = iter.next();
|
|
101
|
+
if (next.done == true) {
|
|
102
|
+
return { s: out, is_prefix: true };
|
|
103
|
+
}
|
|
104
|
+
else if (next.value == "*") {
|
|
105
|
+
out += "*";
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
throw `a single '*' is only allowed at the end for wildcards; please use '**' instead`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
out += elt.value;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function interpConfig(rawcfg) {
|
|
117
|
+
var _a, _b;
|
|
118
|
+
function interpPath(name, str) {
|
|
119
|
+
try {
|
|
120
|
+
return unescape(str);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
throw `${name}: ${err}`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function interpRedirect(i, r) {
|
|
127
|
+
var _a;
|
|
128
|
+
const rfrom = interpPath(`Redirect ${i}, from`, r.from);
|
|
129
|
+
const rto = interpPath(`Redirect ${i}, to`, r.to);
|
|
130
|
+
let redirect;
|
|
131
|
+
if (rfrom.is_prefix) {
|
|
132
|
+
// This is a bucket redirect
|
|
133
|
+
const toURI = URI.parse(rto.s);
|
|
134
|
+
if (toURI.error) {
|
|
135
|
+
throw `Redirect ${i}, 'to': ${toURI.error}`;
|
|
136
|
+
}
|
|
137
|
+
if (toURI.scheme && toURI.scheme != "http" && toURI.scheme != "https") {
|
|
138
|
+
throw `Redirect ${i}, 'to': unsupported URI scheme ${toURI.scheme}; http or https required`;
|
|
139
|
+
}
|
|
140
|
+
if (toURI.port) {
|
|
141
|
+
throw `Redirect ${i}, 'to': specifying a port is not supported`;
|
|
142
|
+
}
|
|
143
|
+
if (toURI.query) {
|
|
144
|
+
throw `Redirect ${i}, 'to': specifying a query is not supported`;
|
|
145
|
+
}
|
|
146
|
+
if (toURI.fragment) {
|
|
147
|
+
throw `Redirect ${i}, 'to': specifying a fragment is not supported`;
|
|
148
|
+
}
|
|
149
|
+
let path = (_a = toURI.path) !== null && _a !== void 0 ? _a : "";
|
|
150
|
+
// - In case of an absolute URL e.g. `https://foo.net/abc`, toURI.path will
|
|
151
|
+
// be equal to `/abc`, leading to redirect URLs with a double slash e.g.
|
|
152
|
+
// `https://foo.net//abc`. This works but removing the extra / in nicer.
|
|
153
|
+
// - In case of a "relative" URL e.g. `/abc`, setting this as redirect
|
|
154
|
+
// will result in garage sending `//abc` as redirect, which does not
|
|
155
|
+
// result in a working relative path. So we remove the extra / in this
|
|
156
|
+
// case as well.
|
|
157
|
+
if (path.startsWith("/")) {
|
|
158
|
+
path = path.substring(1);
|
|
159
|
+
}
|
|
160
|
+
// Similarly, if the user used `from = "/a*"` they likely meant to ask for
|
|
161
|
+
// `from = "a*"`, so we remove a level of `/` here as well. Otherwise,
|
|
162
|
+
// the redirect would only match for queries with double slashes, e.g.
|
|
163
|
+
// `https://foo.net//a`.
|
|
164
|
+
let prefix = rfrom.s;
|
|
165
|
+
if (prefix.startsWith("/")) {
|
|
166
|
+
prefix = prefix.substring(1);
|
|
167
|
+
}
|
|
168
|
+
let bredirect = {
|
|
169
|
+
prefix,
|
|
170
|
+
if_error: undefined,
|
|
171
|
+
hostname: toURI.host,
|
|
172
|
+
protocol: toURI.scheme,
|
|
173
|
+
// Unless specified, set the status to 302.
|
|
174
|
+
// Garage returns status=302 when reading back a bucket redirection
|
|
175
|
+
// that was written with status=undefined, and we want to be able to
|
|
176
|
+
// read back the same redirect as we wrote to be able to properly
|
|
177
|
+
// detect changes.
|
|
178
|
+
status: 302,
|
|
179
|
+
to: rto.is_prefix
|
|
180
|
+
? { kind: "replace_prefix", prefix: path }
|
|
181
|
+
: { kind: "replace", target: path },
|
|
182
|
+
};
|
|
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
|
+
if (r.status) {
|
|
190
|
+
if ([301, 302, 303, 307, 308].includes(r.status)) {
|
|
191
|
+
bredirect.status = r.status;
|
|
192
|
+
}
|
|
193
|
+
else if ([200, 404].includes(r.status)) {
|
|
194
|
+
if (toURI.host || toURI.scheme) {
|
|
195
|
+
throw `Redirect ${i}: a status of 200 or 404 is not accepted while specifying a protocol and hostname in the destination`;
|
|
196
|
+
}
|
|
197
|
+
bredirect.status = r.status;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
throw `Redirect ${i}: unsupported status code ${r.status}`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
redirect = { kind: "bucket", r: bredirect };
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
// This is an object redirect
|
|
207
|
+
if (r.if_error) {
|
|
208
|
+
throw `Redirect ${i}: 'if_error' is not supported for single redirections`;
|
|
209
|
+
}
|
|
210
|
+
if (r.status) {
|
|
211
|
+
throw (`Redirect ${i}: 'status' is not supported for single redirections.\n` +
|
|
212
|
+
"All single redirections have status code 301.");
|
|
213
|
+
}
|
|
214
|
+
if (rto.is_prefix) {
|
|
215
|
+
throw `Redirect ${i}: cannot use a prefix target in 'to' when 'from' is a single address`;
|
|
216
|
+
}
|
|
217
|
+
// if 'from' starts with a /, consider that the user meant to refer to an object
|
|
218
|
+
// at the root of the bucket and not an object with "/" in its name. Thus we should
|
|
219
|
+
// remove the /.
|
|
220
|
+
let from = rfrom.s;
|
|
221
|
+
if (from.startsWith("/")) {
|
|
222
|
+
from = from.substring(1);
|
|
223
|
+
}
|
|
224
|
+
// for the 'to' field, garage expects a target that starts with 'http://', 'https://'
|
|
225
|
+
// or '/'. So if the 'to' field does not start with '/', add one...
|
|
226
|
+
let to = rto.s;
|
|
227
|
+
if (!(to.startsWith("http://") ||
|
|
228
|
+
to.startsWith("https://") ||
|
|
229
|
+
to.startsWith("/"))) {
|
|
230
|
+
to = "/".concat(to);
|
|
231
|
+
}
|
|
232
|
+
redirect = { kind: "object", from, to };
|
|
233
|
+
}
|
|
234
|
+
return redirect;
|
|
235
|
+
}
|
|
236
|
+
let cfg = {
|
|
237
|
+
// 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,
|
|
240
|
+
bucket_redirects: [],
|
|
241
|
+
object_redirects: new Map(),
|
|
242
|
+
};
|
|
243
|
+
for (const [i, raw] of ((_b = rawcfg.redirects) !== null && _b !== void 0 ? _b : []).entries()) {
|
|
244
|
+
// `i+1` is only used for display: start counting redirects from 1 instead of 0
|
|
245
|
+
const r = interpRedirect(i + 1, raw);
|
|
246
|
+
if (r.kind == "bucket") {
|
|
247
|
+
cfg.bucket_redirects.push(r.r);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
if (cfg.object_redirects.has(r.from)) {
|
|
251
|
+
throw `Cannot have two redirects from the same path ${r.from}`;
|
|
252
|
+
}
|
|
253
|
+
cfg.object_redirects.set(r.from, r.to);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return cfg;
|
|
257
|
+
}
|
|
258
|
+
export function readConfigFile(filename) {
|
|
259
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
260
|
+
return interpConfig(interpRawConfig(yield readConfigFileObject(filename)));
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
////////////// Reading from a S3 bucket
|
|
264
|
+
export function getBucketConfig(bucket, files) {
|
|
265
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
266
|
+
// This function performs the following S3 requests:
|
|
267
|
+
// - GetBucketWebsite to get bucket-level redirects;
|
|
268
|
+
// - A Head command for each file in the bucket to collect object redirects.
|
|
269
|
+
// (This can become relatively slow for buckets with thousands of files,
|
|
270
|
+
// but I don't know of a better way.)
|
|
271
|
+
var _a, _b;
|
|
272
|
+
const [response, details] = yield Promise.all([
|
|
273
|
+
bucket.client.send(new GetBucketWebsiteCommand({ Bucket: bucket.name })),
|
|
274
|
+
getBucketFilesDetails(bucket, files),
|
|
275
|
+
]);
|
|
276
|
+
if (response && response.$metadata.httpStatusCode != 200) {
|
|
277
|
+
throw `Error sending GetBucketWebsite: ${response}`;
|
|
278
|
+
}
|
|
279
|
+
// Collect object redirects
|
|
280
|
+
const object_redirects = new Map();
|
|
281
|
+
for (const [file, { redirect }] of details) {
|
|
282
|
+
if (redirect) {
|
|
283
|
+
object_redirects.set(file, redirect);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Interpret bucket redirects
|
|
287
|
+
if (response.RedirectAllRequestsTo) {
|
|
288
|
+
// NB: garage does not currently support RedirectAllRequestsTo so this should never happen
|
|
289
|
+
throw (`remote website configuration: RedirectAllRequestsTo is specified; ` +
|
|
290
|
+
`this is currently unsupported by dxfl`);
|
|
291
|
+
}
|
|
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;
|
|
294
|
+
let bucket_redirects = [];
|
|
295
|
+
if (response.RoutingRules) {
|
|
296
|
+
for (const rule of response.RoutingRules) {
|
|
297
|
+
// If no Condition is specified, then the redirect always applies, which is equivalent
|
|
298
|
+
// to matching on an empty prefix
|
|
299
|
+
let prefix = "";
|
|
300
|
+
let if_error = undefined;
|
|
301
|
+
if (rule.Condition) {
|
|
302
|
+
if (rule.Condition.HttpErrorCodeReturnedEquals) {
|
|
303
|
+
if (rule.Condition.HttpErrorCodeReturnedEquals == "404") {
|
|
304
|
+
if_error = 404;
|
|
305
|
+
}
|
|
306
|
+
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`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (rule.Condition.KeyPrefixEquals) {
|
|
313
|
+
prefix = rule.Condition.KeyPrefixEquals;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const hostname = rule.Redirect.HostName;
|
|
317
|
+
const status = rule.Redirect.HttpRedirectCode
|
|
318
|
+
? parseInt(rule.Redirect.HttpRedirectCode)
|
|
319
|
+
: undefined;
|
|
320
|
+
let protocol = undefined;
|
|
321
|
+
if (rule.Redirect.Protocol) {
|
|
322
|
+
if (rule.Redirect.Protocol == "http" ||
|
|
323
|
+
rule.Redirect.Protocol == "https") {
|
|
324
|
+
protocol = rule.Redirect.Protocol;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
// currently not supported by garage
|
|
328
|
+
throw (`remote website configuration: 'protocol' is neither http or https; ` +
|
|
329
|
+
`this is currently unsupported by dxfl`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
let to;
|
|
333
|
+
if (rule.Redirect.ReplaceKeyPrefixWith) {
|
|
334
|
+
to = {
|
|
335
|
+
kind: "replace_prefix",
|
|
336
|
+
prefix: rule.Redirect.ReplaceKeyPrefixWith,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
else if (rule.Redirect.ReplaceKeyWith) {
|
|
340
|
+
to = { kind: "replace", target: rule.Redirect.ReplaceKeyWith };
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
// not completely sure whether this is the correct behavior, but it should
|
|
344
|
+
// match garage's implementation
|
|
345
|
+
to = { kind: "replace", target: "" };
|
|
346
|
+
}
|
|
347
|
+
bucket_redirects.push({
|
|
348
|
+
prefix,
|
|
349
|
+
if_error,
|
|
350
|
+
hostname,
|
|
351
|
+
status,
|
|
352
|
+
protocol,
|
|
353
|
+
to,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { index_page, error_page, bucket_redirects, object_redirects };
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
////////////// Applying a configuration to S3
|
|
361
|
+
// applies the bucket-wide part of the website configuration
|
|
362
|
+
export function putBucketWebsiteConfig(bucket, index_page, error_page, bucket_redirects) {
|
|
363
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
364
|
+
// TODO: printing to show what changes are being applied?
|
|
365
|
+
const WebsiteConfiguration = {};
|
|
366
|
+
if (error_page) {
|
|
367
|
+
WebsiteConfiguration.ErrorDocument = { Key: error_page };
|
|
368
|
+
}
|
|
369
|
+
if (index_page) {
|
|
370
|
+
WebsiteConfiguration.IndexDocument = { Suffix: index_page };
|
|
371
|
+
}
|
|
372
|
+
if (bucket_redirects.length > 0) {
|
|
373
|
+
WebsiteConfiguration.RoutingRules = [];
|
|
374
|
+
for (const r of bucket_redirects) {
|
|
375
|
+
const rule = {
|
|
376
|
+
Condition: {
|
|
377
|
+
KeyPrefixEquals: r.prefix,
|
|
378
|
+
},
|
|
379
|
+
Redirect: {},
|
|
380
|
+
};
|
|
381
|
+
if (r.if_error) {
|
|
382
|
+
rule.Condition.HttpErrorCodeReturnedEquals = r.if_error.toString();
|
|
383
|
+
}
|
|
384
|
+
if (r.hostname) {
|
|
385
|
+
rule.Redirect.HostName = r.hostname;
|
|
386
|
+
}
|
|
387
|
+
if (r.status) {
|
|
388
|
+
rule.Redirect.HttpRedirectCode = r.status;
|
|
389
|
+
}
|
|
390
|
+
if (r.protocol) {
|
|
391
|
+
rule.Redirect.Protocol = r.protocol;
|
|
392
|
+
}
|
|
393
|
+
if (r.to.kind == "replace_prefix") {
|
|
394
|
+
rule.Redirect.ReplaceKeyPrefixWith = r.to.prefix;
|
|
395
|
+
}
|
|
396
|
+
else if (r.to.kind == "replace") {
|
|
397
|
+
rule.Redirect.ReplaceKeyWith = r.to.target;
|
|
398
|
+
}
|
|
399
|
+
WebsiteConfiguration.RoutingRules.push(rule);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const resp = yield bucket.client.send(new PutBucketWebsiteCommand({
|
|
403
|
+
Bucket: bucket.name,
|
|
404
|
+
WebsiteConfiguration,
|
|
405
|
+
}));
|
|
406
|
+
if (resp && resp.$metadata.httpStatusCode != 200) {
|
|
407
|
+
throw resp;
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dxfl",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "",
|
|
5
5
|
"license": "EUPL-1.2",
|
|
6
6
|
"author": "Deuxfleurs Team <coucou@deuxfleurs.fr>",
|
|
@@ -10,17 +10,28 @@
|
|
|
10
10
|
"dxfl": "dist/index.js"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"prepare": "npx tsc"
|
|
13
|
+
"prepare": "npx tsc",
|
|
14
|
+
"prettier": "npx prettier . --write",
|
|
15
|
+
"prettier-watch": "npx -y onchange \"**/*\" -- npx prettier --write --ignore-unknown {{changed}}",
|
|
16
|
+
"prettier-check": "npx prettier . --check"
|
|
14
17
|
},
|
|
15
18
|
"dependencies": {
|
|
16
19
|
"@aws-sdk/client-s3": "^3.750.0",
|
|
17
|
-
"@
|
|
20
|
+
"@commander-js/extra-typings": "^13.1.0",
|
|
18
21
|
"@supercharge/promise-pool": "^3.2.0",
|
|
19
22
|
"@types/node": "^22.13.5",
|
|
20
23
|
"commander": "^13.1.0",
|
|
24
|
+
"fast-uri": "^3.0.6",
|
|
21
25
|
"guichet-sdk-ts": "^0.1.0",
|
|
22
26
|
"mime": "^4.0.6",
|
|
23
27
|
"read": "^4.1.0",
|
|
24
|
-
"
|
|
28
|
+
"smol-toml": "^1.3.4",
|
|
29
|
+
"typescript": "^5.7.3",
|
|
30
|
+
"zod": "^3.24.4",
|
|
31
|
+
"zod-validation-error": "^3.4.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"onchange": "^7.1.0",
|
|
35
|
+
"prettier": "3.5.3"
|
|
25
36
|
}
|
|
26
37
|
}
|