Repository navigation
Expand file tree
/
Copy pathindex.js
More file actions
273 lines (252 loc) · 8.13 KB
/
Copy pathindex.js
File metadata and controls
273 lines (252 loc) · 8.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
import * as Minio from "minio";
import fs from "fs/promises";
import os from "os";
import process from "process";
import path from "path";
import mime from "mime";
import async from "async";
import ms from "ms";
import { minimatch } from "minimatch";
import { program } from "commander";
const CREDENTIALS_FILE = path.join(os.homedir(), ".abc-credentials");
const DEFAULT_PROFILE = "newsDigitalSpace";
const DEFAULT_PORT = 443;
const UPLOAD_CONCURRENCY = 10;
const SECRET_KEYS = ["accessKey", "secretKey"];
const DEFAULT_MAX_AGE = "10m";
/** Prefix for every log line, so output is consistent and sortable. */
const timestamp = () => `[${new Date().toISOString()}]`;
const log = (...args) => console.log(timestamp(), ...args);
const logError = (...args) => console.error(timestamp(), ...args);
// Required options are validated in go() after merging in ~/.abc-credentials,
// so they are declared as plain options here.
program
.option(
"-e, --end-point <server>",
"S3 endpoint, e.g. sgp1.digitaloceanspaces.com",
)
.option("-p, --port <port>", "Port to connect over")
.option("-a, --access-key <accessKey>", "s3 access key")
.option("-k, --secret-key <secretKey>", "s3 secret key")
.option("-b, --bucket <bucket>", "Bucket to put files in")
.option(
"--profile <name>",
`Profile to read from ${CREDENTIALS_FILE}`,
DEFAULT_PROFILE,
)
.requiredOption("-s, --src <srcDir>", "source directory to publish", "dist/")
.requiredOption(
"-d, --dest <destDir>",
"directory on the S3 server to publish to",
)
.option(
"-m, --max-age <rules>",
`Cache max-age as semicolon-separated "glob:duration" rules, e.g. 'aus.json:365days;*:20min'. ` +
`Durations use the 'ms' format. see https://www.npmjs.com/package/ms` +
`Files matching no rule get ${DEFAULT_MAX_AGE}.`,
)
.addHelpText(
"after",
`
Defaults for endPoint, port, accessKey, secretKey, bucket and maxAge are read from
${CREDENTIALS_FILE} (if it exists). Command line options take precedence.`,
);
program.parse();
/**
* Load a profile from ~/.abc-credentials, if the file exists.
* @param {string} profile - Key within the credentials file
* @returns Object with any of endPoint, port, accessKey, secretKey, bucket
**/
async function loadCredentialDefaults(profile) {
try {
const raw = await fs.readFile(CREDENTIALS_FILE, "utf8");
const creds = JSON.parse(raw);
const entry = creds[profile];
if (!entry) {
return {};
}
return entry;
} catch (e) {
throw new Error(`${CREDENTIALS_FILE} is not valid JSON: ${e.message}`);
}
}
/**
* Copy of the options that is safe to print: credentials are masked.
* @param {object} opts
* @returns Object with secrets replaced by "***"
**/
function redact(opts) {
const safe = { ...opts };
SECRET_KEYS.forEach((key) => {
if (safe[key]) safe[key] = "***";
});
return safe;
}
/**
* Convert a duration string (ms format, e.g. "20min", "365days") to whole seconds.
* @param {string} duration
* @returns {number} Seconds, or throws if the duration is invalid/negative
**/
function durationToSeconds(duration) {
let millis;
try {
millis = ms(duration);
} catch {
millis = undefined;
}
if (typeof millis !== "number" || !Number.isFinite(millis) || millis < 0) {
throw new Error(`Invalid duration "${duration}"`);
}
return Math.floor(millis / 1000);
}
/**
* Parse a max-age spec such as 'aus.json:365days;*:20min' into ordered rules.
* @param {string} spec - Semicolon-separated "glob:duration" rules
* @returns Array of { pattern, seconds }
**/
function parseMaxAgeRules(spec) {
return String(spec)
.split(";")
.map((rule) => rule.trim())
.filter(Boolean)
.map((rule) => {
const split = rule.lastIndexOf(":");
// A rule with no ":" is a bare duration that applies to everything
const pattern = split === -1 ? "*" : rule.slice(0, split).trim();
const duration = (split === -1 ? rule : rule.slice(split + 1)).trim();
if (!pattern) {
throw new Error(`Invalid --max-age rule "${rule}": empty glob`);
}
try {
return { pattern, seconds: durationToSeconds(duration) };
} catch (e) {
throw new Error(`Invalid --max-age rule "${rule}": ${e.message}`);
}
});
}
/**
* Find the max-age (in seconds) for a file. The first matching rule wins.
* @param {string} relativePath - Path relative to the source dir, "/" separated
* @param {Array} rules - Output of parseMaxAgeRules
* @param {number} fallback - Seconds to use if no rule matches
**/
function maxAgeFor(relativePath, rules, fallback) {
// matchBase: patterns without a "/" are matched against the file name only
const rule = rules.find((r) =>
minimatch(relativePath, r.pattern, { matchBase: true, dot: true }),
);
return rule ? rule.seconds : fallback;
}
/**
* Recursively list directories to create an array of src/dest files to upload
* @param {string} src - Source directory, an absolute path
* @param {string} dest - Dest dir on the s3 server
* @returns Array of objects with local & remote filenames
**/
async function syncDir({ src, dest }) {
const listing = await fs.readdir(src, { withFileTypes: true });
const files = [];
const dirs = [];
listing.forEach((entry) => {
if (entry.isDirectory()) {
dirs.push(entry.name + "/");
} else {
files.push({
local: path.join(entry.parentPath, entry.name),
remote: path.join(dest, entry.name),
contentType: mime.getType(entry.name),
});
}
});
const extraFiles = await Promise.all(
dirs.map((dir) =>
syncDir({
src: path.join(src, dir),
dest: path.join(dest, dir),
}),
),
);
extraFiles.forEach((newFiles) => {
files.push(...newFiles);
});
return files;
}
async function go() {
const cli = program.opts();
const { profile, ...cliOpts } = cli;
// Precedence: command line > ~/.abc-credentials > built-in default
const fileDefaults = await loadCredentialDefaults(profile);
const opts = {
port: DEFAULT_PORT,
maxAge: DEFAULT_MAX_AGE,
...fileDefaults,
};
for (const [key, value] of Object.entries(cliOpts)) {
if (value !== undefined) opts[key] = value;
}
// Sanity check given options
const errors = [];
[
"bucket",
"endPoint",
"port",
"accessKey",
"secretKey",
"src",
"dest",
].forEach((key) => {
if (!opts[key]) {
errors.push(`${key} is required`);
}
});
// Parse max-age rules up front so a typo fails before anything is uploaded
let maxAgeRules = [];
try {
maxAgeRules = parseMaxAgeRules(opts.maxAge);
} catch (e) {
errors.push(e.message);
}
if (errors.length) {
log(redact(opts));
errors.forEach((e) => logError(e));
throw new Error(
`Required options were missing or invalid. Pass them as flags or add them to ${CREDENTIALS_FILE}.`,
);
}
// Set up s3 client
const { src, dest, bucket, maxAge, ...s3Options } = opts;
s3Options.port = Number(s3Options.port);
const minioClient = new Minio.Client(s3Options);
const defaultMaxAge = durationToSeconds(DEFAULT_MAX_AGE);
const srcAbsolute = path.resolve(process.cwd(), src);
const actions = await syncDir({ src: srcAbsolute, dest });
log(`Uploading ${actions.length} file(s) to ${bucket}`);
// Transfer files, up to UPLOAD_CONCURRENCY at a time. On the first failure
// no further uploads are started; in-flight ones are allowed to finish.
await async.eachLimit(actions, UPLOAD_CONCURRENCY, async (action) => {
const { local, remote, contentType } = action;
const relative = path
.relative(srcAbsolute, local)
.split(path.sep)
.join("/");
const fileMaxAge = maxAgeFor(relative, maxAgeRules, defaultMaxAge);
const metadata = {
"Content-Type": contentType,
"x-amz-acl": "public-read",
"cache-control": `max-age=${fileMaxAge}`,
};
const started = Date.now();
try {
await minioClient.fPutObject(bucket, remote, local, metadata);
} catch (e) {
logError(`FAILED ${remote}: ${e.message}`);
throw e;
}
log(`Uploaded ${remote} (${Date.now() - started}ms)`);
});
log("All uploads complete");
}
go().catch((e) => {
logError(e);
process.exit(1);
});