@aglyn/plugins-redirects 1.0.0-beta.221 → 1.0.0-beta.223
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/package.json +11 -11
- package/src/lib/components/redirects-console-page.js +60 -30
- package/src/lib/components/redirects-console-page.js.map +1 -1
- package/src/lib/declarations.console-server.d.ts +29 -0
- package/src/lib/declarations.console-server.js +69 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/model/redirects.d.ts +75 -0
- package/src/lib/model/redirects.js +73 -0
- package/src/lib/model/redirects.js.map +1 -1
- package/src/lib/plugin.js +13 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/server/resolve-redirect.js +16 -6
- package/src/lib/server/resolve-redirect.js.map +1 -1
- package/src/lib/transfer/redirects-transfer-console.d.ts +18 -0
- package/src/lib/transfer/redirects-transfer-console.js +62 -0
- package/src/lib/transfer/redirects-transfer-console.js.map +1 -0
- package/src/lib/transfer/redirects-transfer-fields.d.ts +81 -0
- package/src/lib/transfer/redirects-transfer-fields.js +398 -0
- package/src/lib/transfer/redirects-transfer-fields.js.map +1 -0
- package/src/lib/transfer/redirects-transfer.d.ts +73 -0
- package/src/lib/transfer/redirects-transfer.js +986 -0
- package/src/lib/transfer/redirects-transfer.js.map +1 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
|
|
18
|
+
import { TENANT_APEX } from "@aglyn/aglyn/app-utils/tenant-apex";
|
|
19
|
+
import { hostRoleCanPublish, hostRoleCanWrite } from "@aglyn/aglyn/app-utils/organizations";
|
|
20
|
+
import { checkEntitlement, checkQuota } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
21
|
+
import { ACKNOWLEDGED_WARNING_CLASSES, TRANSFER_ID_FIELD, TRANSFER_WARNING_CLASSES, buildTransferPlan, isBlankTransferValue, matchLookupKey, planTransferUndo, transferValuesEqual, withTransferResourceFindings } from "@aglyn/aglyn/data-transfer";
|
|
22
|
+
import { pluginHostResource } from "@aglyn/aglyn/plugin-manager/plugin-host-resources";
|
|
23
|
+
import { dropPluginSiteCache } from "@aglyn/aglyn/plugin-manager/plugin-site-cache";
|
|
24
|
+
import { FieldPath, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
25
|
+
import { REDIRECT_DEFAULT_PRIORITY, REDIRECT_STATUS_CODES, findDuplicateRedirect, isExternalRedirectDestination, isSelfRedirect, normalizeRedirectDestination, normalizeRedirectSource, redirectHitKey, redirectSourceIsLivePage, validateRedirectRule, walkRedirectChain } from "../model/redirects.js";
|
|
26
|
+
import { REDIRECTS_ALIAS_DICTIONARIES, REDIRECTS_DEFAULT_POLICY, REDIRECTS_MATCH_KEYS, REDIRECT_FIELD, REDIRECT_HOST_RESOURCE_KIND, REDIRECT_WRITABLE_FIELDS, canonicalRedirectKind, redirectValuesEqual, redirectsTransferCatalog } from "./redirects-transfer-fields.js";
|
|
27
|
+
/** How long before the chunk's budget runs out a row is not started. */ const ROW_RESERVE_MS = 2000;
|
|
28
|
+
/** The most rows one export page reads. */ const PAGE_SIZE_DEFAULT = 500;
|
|
29
|
+
const PAGE_SIZE_MAX = 1000;
|
|
30
|
+
/** Values one `in` query may name. */ const IN_QUERY_MAX = 30;
|
|
31
|
+
/** Documents one `getAll` reads. */ const GET_ALL_MAX = 100;
|
|
32
|
+
/** Days the hit count sums, as the redirects page does. */ const HIT_DAYS = 30;
|
|
33
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
function text(value) {
|
|
35
|
+
return typeof value === 'string' ? value : '';
|
|
36
|
+
}
|
|
37
|
+
/** A stored time as ISO text, or `null`. */ function isoOf(value) {
|
|
38
|
+
if (value === null || value === undefined) return null;
|
|
39
|
+
if (typeof value.toMillis === 'function') {
|
|
40
|
+
return new Date(value.toMillis()).toISOString();
|
|
41
|
+
}
|
|
42
|
+
if (value instanceof Date) return value.toISOString();
|
|
43
|
+
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
function validStatusCode(value) {
|
|
47
|
+
return REDIRECT_STATUS_CODES.includes(value);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A stored rule's fields as the redirects page reads them into its editor: a
|
|
51
|
+
* missing mode is exact, a missing priority 100, a status code outside the
|
|
52
|
+
* four is 302 (as the serve path reads it), and a rule is on unless it says
|
|
53
|
+
* `false`. The export, the dry run's "before" and undo all read this, so a
|
|
54
|
+
* file exported and imported back is unchanged.
|
|
55
|
+
*/ export function redirectRuleValues(id, data) {
|
|
56
|
+
const priority = data['priority'];
|
|
57
|
+
return {
|
|
58
|
+
[TRANSFER_ID_FIELD]: id,
|
|
59
|
+
source: text(data['source']),
|
|
60
|
+
kind: typeof data['kind'] === 'string' && data['kind'] ? data['kind'] : 'exact',
|
|
61
|
+
destination: text(data['destination']),
|
|
62
|
+
statusCode: validStatusCode(data['statusCode']) ? data['statusCode'] : 302,
|
|
63
|
+
priority: priority !== null && priority !== undefined && Number.isFinite(Number(priority)) ? Number(priority) : REDIRECT_DEFAULT_PRIORITY,
|
|
64
|
+
enabled: data['enabled'] !== false,
|
|
65
|
+
[REDIRECT_FIELD.approvedBy]: typeof data[REDIRECT_FIELD.approvedBy] === 'string' && data[REDIRECT_FIELD.approvedBy] ? data[REDIRECT_FIELD.approvedBy] : null,
|
|
66
|
+
createdAt: isoOf(data['createdAt']),
|
|
67
|
+
updatedAt: isoOf(data['updatedAt']),
|
|
68
|
+
createdBy: typeof data['createdBy'] === 'string' ? data['createdBy'] : null,
|
|
69
|
+
lastHitAt: isoOf(data['lastHitAt'])
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** A rule as the whole-set checks read it: as stored, like the page's rows. */ function ruleRow(id, data) {
|
|
73
|
+
return {
|
|
74
|
+
$id: id,
|
|
75
|
+
source: data['source'],
|
|
76
|
+
destination: data['destination'],
|
|
77
|
+
kind: data['kind']
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function isLive(data) {
|
|
81
|
+
return Boolean(data) && !(data == null ? void 0 : data['deletedAt']);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The from-path as a rule of `kind` stores it: a pattern trimmed, a path
|
|
85
|
+
* normalized (lowercase, no trailing slash, no query); `null` when a path
|
|
86
|
+
* cannot be one.
|
|
87
|
+
*/ function storedSource(kind, source) {
|
|
88
|
+
if (kind === 'regex') return source.trim() || null;
|
|
89
|
+
return normalizeRedirectSource(source);
|
|
90
|
+
}
|
|
91
|
+
/** What makes two rules one: their mode and their from-path, as stored. */ function ruleKey(kind, source) {
|
|
92
|
+
const stored = storedSource(kind, source);
|
|
93
|
+
return stored === null ? null : `${kind}\u0000${stored}`;
|
|
94
|
+
}
|
|
95
|
+
/** The address a rule answers at, for the site cache: a pattern names no one path, so `/`. */ function announcedPath(rule) {
|
|
96
|
+
var _rule_kind;
|
|
97
|
+
const source = text(rule.source);
|
|
98
|
+
if (((_rule_kind = rule.kind) != null ? _rule_kind : 'exact') === 'regex') return '/';
|
|
99
|
+
return source.startsWith('/') ? source : null;
|
|
100
|
+
}
|
|
101
|
+
/** The rule a row leaves: what the record held (or the defaults), with the row's changes. */ function finalRule(before, diff) {
|
|
102
|
+
const values = _extends({}, before != null ? before : {});
|
|
103
|
+
for (const change of diff)values[change.fieldId] = change.after;
|
|
104
|
+
return withDefaults(values);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The six fields with the page's defaults where a value is missing:
|
|
108
|
+
* exact, 302, priority 100, on.
|
|
109
|
+
*/ function withDefaults(values) {
|
|
110
|
+
const priority = values['priority'];
|
|
111
|
+
const kind = canonicalRedirectKind(values['kind']);
|
|
112
|
+
return {
|
|
113
|
+
source: text(values['source']),
|
|
114
|
+
kind: isBlankTransferValue(values['kind']) ? 'exact' : kind != null ? kind : String(values['kind']),
|
|
115
|
+
destination: text(values['destination']),
|
|
116
|
+
statusCode: isBlankTransferValue(values['statusCode']) ? 302 : Number(values['statusCode']),
|
|
117
|
+
priority: priority !== null && priority !== undefined && Number.isFinite(Number(priority)) ? Number(priority) : REDIRECT_DEFAULT_PRIORITY,
|
|
118
|
+
enabled: values['enabled'] !== false
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** The site's own addresses, which a destination may always name. */ function ownDomains(host) {
|
|
122
|
+
return [
|
|
123
|
+
typeof host['cname'] === 'string' ? host['cname'] : null,
|
|
124
|
+
typeof host['subdomain'] === 'string' && host['subdomain'] ? `${host['subdomain']}.${TENANT_APEX}` : null
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Why the page would refuse to save this rule, or `null`: an unknown mode, a
|
|
129
|
+
* from-path or pattern it cannot use, a destination it cannot use or that
|
|
130
|
+
* wears another company's address, a status code it does not offer, or a
|
|
131
|
+
* path redirected to itself.
|
|
132
|
+
*/ function ruleProblem(rule, host) {
|
|
133
|
+
const problem = validateRedirectRule({
|
|
134
|
+
kind: rule.kind,
|
|
135
|
+
source: rule.source,
|
|
136
|
+
destination: rule.destination
|
|
137
|
+
}, {
|
|
138
|
+
ownDomains: ownDomains(host)
|
|
139
|
+
});
|
|
140
|
+
if (problem) {
|
|
141
|
+
var _rule_fieldId;
|
|
142
|
+
const fieldId = problem === 'Unknown match mode' ? REDIRECT_FIELD.kind : /destination/i.test(problem) ? REDIRECT_FIELD.destination : REDIRECT_FIELD.source;
|
|
143
|
+
return {
|
|
144
|
+
detail: problem,
|
|
145
|
+
fieldId,
|
|
146
|
+
value: String((_rule_fieldId = rule[fieldId]) != null ? _rule_fieldId : '')
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (!validStatusCode(rule.statusCode)) {
|
|
150
|
+
return {
|
|
151
|
+
detail: 'Status codes are 301, 302, 307 or 308',
|
|
152
|
+
fieldId: REDIRECT_FIELD.statusCode,
|
|
153
|
+
value: String(rule.statusCode)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (rule.kind !== 'regex' && isSelfRedirect({
|
|
157
|
+
source: rule.source,
|
|
158
|
+
destination: rule.destination
|
|
159
|
+
})) {
|
|
160
|
+
return {
|
|
161
|
+
detail: 'That would redirect the path to itself',
|
|
162
|
+
fieldId: REDIRECT_FIELD.destination,
|
|
163
|
+
value: rule.destination
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
/** The rule's fields as stored: the page's normalization, and only what the create route's allow-list names. */ function storedFields(rule, allowed) {
|
|
169
|
+
var _normalizeRedirectSource, _normalizeRedirectDestination;
|
|
170
|
+
const normalized = {
|
|
171
|
+
source: rule.kind === 'regex' ? rule.source.trim() : (_normalizeRedirectSource = normalizeRedirectSource(rule.source)) != null ? _normalizeRedirectSource : rule.source,
|
|
172
|
+
destination: (_normalizeRedirectDestination = normalizeRedirectDestination(rule.destination)) != null ? _normalizeRedirectDestination : rule.destination,
|
|
173
|
+
statusCode: rule.statusCode,
|
|
174
|
+
kind: rule.kind,
|
|
175
|
+
priority: rule.priority,
|
|
176
|
+
enabled: rule.enabled
|
|
177
|
+
};
|
|
178
|
+
return Object.fromEntries(Object.entries(normalized).filter(([key])=>allowed.includes(key)));
|
|
179
|
+
}
|
|
180
|
+
function hostnameOf(destination) {
|
|
181
|
+
try {
|
|
182
|
+
return new URL(destination).hostname || destination;
|
|
183
|
+
} catch (unused) {
|
|
184
|
+
return destination;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function requireHost(ctx) {
|
|
188
|
+
if (!ctx.hostId) throw new Error('Redirects belong to a site; open the import from the site.');
|
|
189
|
+
return ctx.hostId;
|
|
190
|
+
}
|
|
191
|
+
/** The redirect kind's declaration, which the create route reads too. */ function redirectDeclaration() {
|
|
192
|
+
const declared = pluginHostResource(REDIRECT_HOST_RESOURCE_KIND);
|
|
193
|
+
if (!declared) throw new Error(`The "${REDIRECT_HOST_RESOURCE_KIND}" kind is not declared.`);
|
|
194
|
+
return declared;
|
|
195
|
+
}
|
|
196
|
+
/** Field ids → values, holding only `fieldIds`. */ function pick(values, fieldIds) {
|
|
197
|
+
var _values_fieldId;
|
|
198
|
+
const row = {};
|
|
199
|
+
for (const fieldId of fieldIds)row[fieldId] = (_values_fieldId = values[fieldId]) != null ? _values_fieldId : null;
|
|
200
|
+
return row;
|
|
201
|
+
}
|
|
202
|
+
/** Each `size` items of `items`. */ function slices(items, size) {
|
|
203
|
+
const out = [];
|
|
204
|
+
for(let at = 0; at < items.length; at += size)out.push(items.slice(at, at + size));
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The plan with every create past `remaining` failed as `planLimit`, counted
|
|
209
|
+
* after the rows the plugin refused — a refused row takes no slot.
|
|
210
|
+
*/ function withCreateLimit(plan, remaining, limit) {
|
|
211
|
+
var _ref, _ref1, _ref2, _ref3;
|
|
212
|
+
if (!Number.isFinite(remaining)) return plan;
|
|
213
|
+
let creates = 0;
|
|
214
|
+
const held = [];
|
|
215
|
+
const rows = plan.rows.map((row)=>{
|
|
216
|
+
if (row.verdict !== 'create') return row;
|
|
217
|
+
if (creates < remaining) {
|
|
218
|
+
creates += 1;
|
|
219
|
+
return row;
|
|
220
|
+
}
|
|
221
|
+
const failed = _extends({}, row, {
|
|
222
|
+
verdict: 'fail',
|
|
223
|
+
reason: 'planLimit',
|
|
224
|
+
diff: [],
|
|
225
|
+
warnings: row.warnings.includes('planLimit') ? row.warnings : [
|
|
226
|
+
...row.warnings,
|
|
227
|
+
'planLimit'
|
|
228
|
+
]
|
|
229
|
+
});
|
|
230
|
+
held.push(failed);
|
|
231
|
+
return failed;
|
|
232
|
+
});
|
|
233
|
+
if (!held.length) return plan;
|
|
234
|
+
const summary = {
|
|
235
|
+
create: 0,
|
|
236
|
+
update: 0,
|
|
237
|
+
unchanged: 0,
|
|
238
|
+
skip: 0,
|
|
239
|
+
fail: 0,
|
|
240
|
+
total: rows.length
|
|
241
|
+
};
|
|
242
|
+
for (const row of rows)summary[row.verdict] += 1;
|
|
243
|
+
const detail = `Your plan includes ${limit} redirects on this site`;
|
|
244
|
+
const prior = plan.warnings.find((entry)=>entry.class === 'planLimit');
|
|
245
|
+
const raised = {
|
|
246
|
+
class: 'planLimit',
|
|
247
|
+
count: ((_ref = prior == null ? void 0 : prior.count) != null ? _ref : 0) + held.length,
|
|
248
|
+
rows: ((_ref1 = prior == null ? void 0 : prior.rows) != null ? _ref1 : 0) + held.length,
|
|
249
|
+
fieldIds: (_ref2 = prior == null ? void 0 : prior.fieldIds) != null ? _ref2 : [],
|
|
250
|
+
samples: [
|
|
251
|
+
...(_ref3 = prior == null ? void 0 : prior.samples) != null ? _ref3 : [],
|
|
252
|
+
...held.map((row)=>({
|
|
253
|
+
row: row.index,
|
|
254
|
+
detail
|
|
255
|
+
}))
|
|
256
|
+
].slice(0, 5),
|
|
257
|
+
requiresAcknowledgement: ACKNOWLEDGED_WARNING_CLASSES.includes('planLimit')
|
|
258
|
+
};
|
|
259
|
+
const warnings = TRANSFER_WARNING_CLASSES.flatMap((entry)=>entry === 'planLimit' ? [
|
|
260
|
+
raised
|
|
261
|
+
] : plan.warnings.filter((warning)=>warning.class === entry));
|
|
262
|
+
return {
|
|
263
|
+
rows,
|
|
264
|
+
summary,
|
|
265
|
+
warnings,
|
|
266
|
+
acknowledgementsRequired: warnings.filter((entry)=>entry.requiresAcknowledgement).map((entry)=>entry.class)
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/*==========================================
|
|
270
|
+
* MATCHING, DEFAULTS AND SAMENESS (AGL-3548)
|
|
271
|
+
*=========================================*/ /**
|
|
272
|
+
* Each row's rule, as the page tells rules apart: a row the engine found by
|
|
273
|
+
* its id keeps that rule; any other row is the rule of its mode (exact when
|
|
274
|
+
* the file gives none) at its from-path as stored — so `/blog` names the
|
|
275
|
+
* exact rule or the prefix rule beside it by the row's mode, never both.
|
|
276
|
+
* Two rows naming one rule are both matched to it here, and the plan refuses
|
|
277
|
+
* the later one, saying which row it repeats, rather than holding it back as
|
|
278
|
+
* a duplicate. `existing` holds every live rule's values by id; running this
|
|
279
|
+
* over its own outcomes changes nothing.
|
|
280
|
+
*/ export function redirectMatchOutcomes(rows, engine, existing, live) {
|
|
281
|
+
// Each live rule under its mode and stored from-path.
|
|
282
|
+
const byKey = new Map();
|
|
283
|
+
for (const { id, data } of live){
|
|
284
|
+
var _byKey_get;
|
|
285
|
+
const key = ruleKey(text(data['kind']) || 'exact', text(data['source']));
|
|
286
|
+
if (key) byKey.set(key, [
|
|
287
|
+
...(_byKey_get = byKey.get(key)) != null ? _byKey_get : [],
|
|
288
|
+
id
|
|
289
|
+
]);
|
|
290
|
+
}
|
|
291
|
+
return rows.map((values, at)=>{
|
|
292
|
+
var _engine_at, _byKey_get;
|
|
293
|
+
const found = (_engine_at = engine[at]) != null ? _engine_at : {
|
|
294
|
+
kind: 'new'
|
|
295
|
+
};
|
|
296
|
+
const byId = found.kind !== 'new' && found.via.fieldId === TRANSFER_ID_FIELD;
|
|
297
|
+
const record = found.kind === 'matched' && byId ? existing.get(found.recordId) : undefined;
|
|
298
|
+
const rowKind = isBlankTransferValue(values['kind']) ? text(record == null ? void 0 : record['kind']) || 'exact' : canonicalRedirectKind(values['kind']);
|
|
299
|
+
const source = isBlankTransferValue(values['source']) ? text(record == null ? void 0 : record['source']) : text(values['source']);
|
|
300
|
+
const key = rowKind && source ? ruleKey(rowKind, source) : null;
|
|
301
|
+
if (byId || !key || !rowKind) return found;
|
|
302
|
+
const candidates = (_byKey_get = byKey.get(key)) != null ? _byKey_get : [];
|
|
303
|
+
const via = {
|
|
304
|
+
fieldId: REDIRECT_FIELD.source,
|
|
305
|
+
value: storedSource(rowKind, source)
|
|
306
|
+
};
|
|
307
|
+
if (!candidates.length) return {
|
|
308
|
+
kind: 'new'
|
|
309
|
+
};
|
|
310
|
+
return candidates.length === 1 ? {
|
|
311
|
+
kind: 'matched',
|
|
312
|
+
recordId: candidates[0],
|
|
313
|
+
via
|
|
314
|
+
} : {
|
|
315
|
+
kind: 'ambiguous',
|
|
316
|
+
recordIds: candidates,
|
|
317
|
+
via
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
/*==========================================
|
|
322
|
+
* THE RESOURCE
|
|
323
|
+
*=========================================*/ /**
|
|
324
|
+
* The id a rule an import creates is written under: the job's row, so a
|
|
325
|
+
* write retried after it landed and before the ledger recorded it finds its
|
|
326
|
+
* own rule instead of making a second one. Without a job, a fresh resource id.
|
|
327
|
+
*/ export function importedRedirectId(jobId, row) {
|
|
328
|
+
return jobId ? `${jobId}-${row}` : createResourceUid();
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The redirects resource over `deps.firestore`: `hosts/{hostId}/redirects`,
|
|
332
|
+
* with every hook the job engine and the export route call. See the block
|
|
333
|
+
* above for what each write checks.
|
|
334
|
+
*/ export function createRedirectsTransferResource(deps) {
|
|
335
|
+
var _deps_now, _deps_announcePaths;
|
|
336
|
+
const now = (_deps_now = deps.now) != null ? _deps_now : ()=>Date.now();
|
|
337
|
+
const { firestore } = deps;
|
|
338
|
+
const announce = (_deps_announcePaths = deps.announcePaths) != null ? _deps_announcePaths : async (hostId, paths)=>{
|
|
339
|
+
await dropPluginSiteCache({
|
|
340
|
+
hostIds: [
|
|
341
|
+
hostId
|
|
342
|
+
],
|
|
343
|
+
paths: {
|
|
344
|
+
[hostId]: [
|
|
345
|
+
...paths
|
|
346
|
+
]
|
|
347
|
+
},
|
|
348
|
+
reason: 'redirect rules were imported'
|
|
349
|
+
});
|
|
350
|
+
};
|
|
351
|
+
const hostRef = (hostId)=>firestore.collection('hosts').doc(hostId);
|
|
352
|
+
const rulesOf = (hostId)=>hostRef(hostId).collection('redirects');
|
|
353
|
+
async function readSite(ctx) {
|
|
354
|
+
var _ref, _host_memberRoles;
|
|
355
|
+
const hostId = requireHost(ctx);
|
|
356
|
+
const [snapshot, org] = await Promise.all([
|
|
357
|
+
hostRef(hostId).get(),
|
|
358
|
+
deps.loadOrg(hostId)
|
|
359
|
+
]);
|
|
360
|
+
const host = (_ref = snapshot.exists ? snapshot.data() : null) != null ? _ref : {};
|
|
361
|
+
const declared = redirectDeclaration();
|
|
362
|
+
const role = ctx.actorUid ? ((_host_memberRoles = host['memberRoles']) != null ? _host_memberRoles : {})[ctx.actorUid] : undefined;
|
|
363
|
+
// The route's question, on the route's axis: a kind that is live the
|
|
364
|
+
// moment it exists needs the publishing role, never just the write role.
|
|
365
|
+
const roleOk = declared.requiresPublishRole ? hostRoleCanPublish(role) : hostRoleCanWrite(role);
|
|
366
|
+
// A plan-less workspace resolves as the free plan, which has no redirects.
|
|
367
|
+
const billing = org != null ? org : {};
|
|
368
|
+
let refusal = null;
|
|
369
|
+
if (!snapshot.exists) refusal = 'This site no longer exists.';
|
|
370
|
+
else if (!roleOk) refusal = 'Importing redirects needs a publishing role on this site — ask an editor or admin';
|
|
371
|
+
else if (declared.entitlement && !checkEntitlement(billing, declared.entitlement)) {
|
|
372
|
+
refusal = 'URL redirects are not included in this workspace’s plan — see Billing';
|
|
373
|
+
}
|
|
374
|
+
const limit = declared.quotaKey ? checkQuota(billing, declared.quotaKey, 0).limit : Number.POSITIVE_INFINITY;
|
|
375
|
+
return {
|
|
376
|
+
hostId,
|
|
377
|
+
host,
|
|
378
|
+
org: billing,
|
|
379
|
+
declared,
|
|
380
|
+
refusal,
|
|
381
|
+
limit
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/** Every rule document of the site, soft-deleted ones included. */ async function readAllRules(hostId) {
|
|
385
|
+
const snapshot = await rulesOf(hostId).get();
|
|
386
|
+
return snapshot.docs.map((doc)=>({
|
|
387
|
+
id: doc.id,
|
|
388
|
+
data: doc.data()
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
async function readByIds(hostId, ids) {
|
|
392
|
+
const found = new Map();
|
|
393
|
+
for (const slice of slices([
|
|
394
|
+
...new Set(ids)
|
|
395
|
+
], GET_ALL_MAX)){
|
|
396
|
+
if (!slice.length) continue;
|
|
397
|
+
const snapshots = await firestore.getAll(...slice.map((id)=>rulesOf(hostId).doc(id)));
|
|
398
|
+
for (const snapshot of snapshots){
|
|
399
|
+
const data = snapshot.exists ? snapshot.data() : undefined;
|
|
400
|
+
if (isLive(data)) found.set(snapshot.id, data);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return found;
|
|
404
|
+
}
|
|
405
|
+
/** Each rule's sampled hits over the last thirty days, by its hit key. */ async function readHits(hostId) {
|
|
406
|
+
const today = now();
|
|
407
|
+
const refs = Array.from({
|
|
408
|
+
length: HIT_DAYS
|
|
409
|
+
}, (_, index)=>hostRef(hostId).collection('analytics').doc(new Date(today - index * DAY_MS).toISOString().slice(0, 10)));
|
|
410
|
+
const totals = {};
|
|
411
|
+
for (const snapshot of (await firestore.getAll(...refs))){
|
|
412
|
+
var _snapshot_data_redirects;
|
|
413
|
+
if (!snapshot.exists) continue;
|
|
414
|
+
const day = (_snapshot_data_redirects = snapshot.data()['redirects']) != null ? _snapshot_data_redirects : {};
|
|
415
|
+
for (const [key, count] of Object.entries(day)){
|
|
416
|
+
var _totals_key;
|
|
417
|
+
const value = Number(count);
|
|
418
|
+
if (Number.isFinite(value)) totals[key] = ((_totals_key = totals[key]) != null ? _totals_key : 0) + value;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return totals;
|
|
422
|
+
}
|
|
423
|
+
function exportRow(id, data, fieldIds, hits) {
|
|
424
|
+
var _hits_redirectHitKey;
|
|
425
|
+
const values = redirectRuleValues(id, data);
|
|
426
|
+
if (hits) values[REDIRECT_FIELD.hits] = (_hits_redirectHitKey = hits[redirectHitKey(id)]) != null ? _hits_redirectHitKey : 0;
|
|
427
|
+
return pick(values, fieldIds);
|
|
428
|
+
}
|
|
429
|
+
const resource = {
|
|
430
|
+
fields: ()=>redirectsTransferCatalog(),
|
|
431
|
+
matchKeys: REDIRECTS_MATCH_KEYS,
|
|
432
|
+
aliases: REDIRECTS_ALIAS_DICTIONARIES,
|
|
433
|
+
defaultPolicy: REDIRECTS_DEFAULT_POLICY,
|
|
434
|
+
valuesEqual: redirectValuesEqual,
|
|
435
|
+
/*
|
|
436
|
+
* Rows matched as the page tells rules apart (`redirectMatchOutcomes`),
|
|
437
|
+
* over every live rule of the site — so the Matching step and the
|
|
438
|
+
* Conflicts step show the rule the dry run will update.
|
|
439
|
+
*/ async match (ctx, input) {
|
|
440
|
+
const hostId = requireHost(ctx);
|
|
441
|
+
const live = (await readAllRules(hostId)).filter((entry)=>isLive(entry.data));
|
|
442
|
+
const existing = new Map(input.records);
|
|
443
|
+
for (const { id, data } of live)existing.set(id, redirectRuleValues(id, data));
|
|
444
|
+
const outcomes = redirectMatchOutcomes(input.rows, input.outcomes, existing, live);
|
|
445
|
+
const records = new Map();
|
|
446
|
+
for (const outcome of outcomes){
|
|
447
|
+
const ids = outcome.kind === 'matched' ? [
|
|
448
|
+
outcome.recordId
|
|
449
|
+
] : outcome.kind === 'ambiguous' ? outcome.recordIds : [];
|
|
450
|
+
for (const id of ids){
|
|
451
|
+
const values = existing.get(id);
|
|
452
|
+
if (values && !input.records.has(id)) records.set(id, values);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
outcomes,
|
|
457
|
+
records
|
|
458
|
+
};
|
|
459
|
+
},
|
|
460
|
+
/*
|
|
461
|
+
* `filter` is not read: the redirects page lists every rule and has no
|
|
462
|
+
* filter to export by. `scopeTokens` does not narrow either: rules are a
|
|
463
|
+
* site's, carry no `visibleTo`, and the export route admits a reader to
|
|
464
|
+
* a site's records only when they reach the site.
|
|
465
|
+
*/ async count (ctx, options) {
|
|
466
|
+
const hostId = requireHost(ctx);
|
|
467
|
+
if (options.ids) return (await readByIds(hostId, options.ids)).size;
|
|
468
|
+
const [all, deleted] = await Promise.all([
|
|
469
|
+
rulesOf(hostId).count().get(),
|
|
470
|
+
rulesOf(hostId).where('deletedAt', '!=', null).count().get()
|
|
471
|
+
]);
|
|
472
|
+
return Math.max(0, all.data().count - deleted.data().count);
|
|
473
|
+
},
|
|
474
|
+
async readPage (ctx, cursor, fieldIds, options = {}) {
|
|
475
|
+
var _options_pageSize;
|
|
476
|
+
const hostId = requireHost(ctx);
|
|
477
|
+
const size = Math.min(PAGE_SIZE_MAX, Math.max(1, Math.floor((_options_pageSize = options.pageSize) != null ? _options_pageSize : PAGE_SIZE_DEFAULT)));
|
|
478
|
+
const hits = fieldIds.includes(REDIRECT_FIELD.hits) ? await readHits(hostId) : null;
|
|
479
|
+
if (options.ids) {
|
|
480
|
+
// The selection, in the order it was made; the cursor is how far into it.
|
|
481
|
+
const start = Math.max(0, Number(cursor != null ? cursor : 0) || 0);
|
|
482
|
+
const ids = options.ids.slice(start, start + size);
|
|
483
|
+
const found = await readByIds(hostId, ids);
|
|
484
|
+
const rows = ids.flatMap((id)=>{
|
|
485
|
+
const data = found.get(id);
|
|
486
|
+
return data ? [
|
|
487
|
+
exportRow(id, data, fieldIds, hits)
|
|
488
|
+
] : [];
|
|
489
|
+
});
|
|
490
|
+
return {
|
|
491
|
+
rows,
|
|
492
|
+
next: start + size < options.ids.length ? String(start + size) : null
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
// Ordered by document id, the one order every rule has: a rule with no
|
|
496
|
+
// `source` still exports (the page sorts by source, in the browser).
|
|
497
|
+
let query = rulesOf(hostId).orderBy(FieldPath.documentId()).limit(size);
|
|
498
|
+
if (cursor) query = query.startAfter(cursor);
|
|
499
|
+
const snapshot = await query.get();
|
|
500
|
+
const rows = snapshot.docs.flatMap((doc)=>{
|
|
501
|
+
const data = doc.data();
|
|
502
|
+
return isLive(data) ? [
|
|
503
|
+
exportRow(doc.id, data, fieldIds, hits)
|
|
504
|
+
] : [];
|
|
505
|
+
});
|
|
506
|
+
const last = snapshot.docs[snapshot.docs.length - 1];
|
|
507
|
+
return {
|
|
508
|
+
rows,
|
|
509
|
+
next: snapshot.docs.length === size && last ? last.id : null
|
|
510
|
+
};
|
|
511
|
+
},
|
|
512
|
+
/*
|
|
513
|
+
* By id, and by from-path: each value is read as the stored form of a
|
|
514
|
+
* path (normalized) and of a pattern (trimmed), and a rule answers for it
|
|
515
|
+
* under its own mode. The plan narrows a row's candidates to the row's
|
|
516
|
+
* mode; this answers every rule the value could mean.
|
|
517
|
+
*/ async lookup (ctx, requests) {
|
|
518
|
+
const hostId = requireHost(ctx);
|
|
519
|
+
const lookup = new Map();
|
|
520
|
+
const records = new Map();
|
|
521
|
+
const file = (fieldId, value, id)=>{
|
|
522
|
+
var _lookup_get;
|
|
523
|
+
const key = matchLookupKey(fieldId, value);
|
|
524
|
+
const ids = (_lookup_get = lookup.get(key)) != null ? _lookup_get : [];
|
|
525
|
+
if (!ids.includes(id)) ids.push(id);
|
|
526
|
+
lookup.set(key, ids);
|
|
527
|
+
};
|
|
528
|
+
for (const request of requests){
|
|
529
|
+
if (request.fieldId === TRANSFER_ID_FIELD) {
|
|
530
|
+
const found = await readByIds(hostId, request.values);
|
|
531
|
+
for (const [id, data] of found){
|
|
532
|
+
file(TRANSFER_ID_FIELD, id, id);
|
|
533
|
+
records.set(id, redirectRuleValues(id, data));
|
|
534
|
+
}
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (request.fieldId !== REDIRECT_FIELD.source) continue;
|
|
538
|
+
const stored = new Set();
|
|
539
|
+
for (const value of request.values){
|
|
540
|
+
for (const form of [
|
|
541
|
+
normalizeRedirectSource(value),
|
|
542
|
+
value.trim()
|
|
543
|
+
])if (form) stored.add(form);
|
|
544
|
+
}
|
|
545
|
+
for (const slice of slices([
|
|
546
|
+
...stored
|
|
547
|
+
], IN_QUERY_MAX)){
|
|
548
|
+
const snapshot = await rulesOf(hostId).where('source', 'in', slice).get();
|
|
549
|
+
for (const doc of snapshot.docs){
|
|
550
|
+
const data = doc.data();
|
|
551
|
+
if (!isLive(data)) continue;
|
|
552
|
+
const kind = typeof data['kind'] === 'string' && data['kind'] ? data['kind'] : 'exact';
|
|
553
|
+
const held = storedSource(kind, text(data['source']));
|
|
554
|
+
for (const value of request.values){
|
|
555
|
+
if (held !== null && storedSource(kind, value) === held) {
|
|
556
|
+
file(REDIRECT_FIELD.source, value, doc.id);
|
|
557
|
+
records.set(doc.id, redirectRuleValues(doc.id, data));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
lookup,
|
|
565
|
+
records
|
|
566
|
+
};
|
|
567
|
+
},
|
|
568
|
+
async plan (ctx, input) {
|
|
569
|
+
const site = await readSite(ctx);
|
|
570
|
+
const documents = await readAllRules(site.hostId);
|
|
571
|
+
const live = documents.filter((entry)=>isLive(entry.data));
|
|
572
|
+
const existing = new Map(input.existing);
|
|
573
|
+
for (const { id, data } of live)existing.set(id, redirectRuleValues(id, data));
|
|
574
|
+
// The resource's own matching (also the engine's `match` hook, so the
|
|
575
|
+
// Matching and Conflicts steps showed these outcomes): run again over
|
|
576
|
+
// the site as read here, which leaves outcomes it made unchanged.
|
|
577
|
+
const matches = redirectMatchOutcomes(input.rows.map((row)=>row.values), input.matches, existing, live);
|
|
578
|
+
// The row's values as the page would store them, so the review's
|
|
579
|
+
// before → after compares like with like, and a new rule shows the
|
|
580
|
+
// defaults it is created with.
|
|
581
|
+
const rows = input.rows.map((row, at)=>{
|
|
582
|
+
const match = matches[at];
|
|
583
|
+
const record = match.kind === 'matched' ? existing.get(match.recordId) : undefined;
|
|
584
|
+
const values = _extends({}, row.values);
|
|
585
|
+
const kind = canonicalRedirectKind(values['kind']);
|
|
586
|
+
if (kind) values['kind'] = kind;
|
|
587
|
+
const sourceKind = kind != null ? kind : isBlankTransferValue(values['kind']) ? text(record == null ? void 0 : record['kind']) || 'exact' : null;
|
|
588
|
+
if (typeof values['source'] === 'string' && sourceKind) {
|
|
589
|
+
var _storedSource;
|
|
590
|
+
values['source'] = (_storedSource = storedSource(sourceKind, values['source'])) != null ? _storedSource : values['source'].trim();
|
|
591
|
+
}
|
|
592
|
+
if (typeof values['destination'] === 'string') {
|
|
593
|
+
var _normalizeRedirectDestination;
|
|
594
|
+
values['destination'] = (_normalizeRedirectDestination = normalizeRedirectDestination(values['destination'])) != null ? _normalizeRedirectDestination : values['destination'].trim();
|
|
595
|
+
}
|
|
596
|
+
if (match.kind === 'new') {
|
|
597
|
+
const defaults = {
|
|
598
|
+
kind: 'exact',
|
|
599
|
+
statusCode: 302,
|
|
600
|
+
priority: REDIRECT_DEFAULT_PRIORITY,
|
|
601
|
+
enabled: true
|
|
602
|
+
};
|
|
603
|
+
for (const [fieldId, value] of Object.entries(defaults)){
|
|
604
|
+
if (isBlankTransferValue(values[fieldId])) values[fieldId] = value;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return _extends({}, row, {
|
|
608
|
+
values
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
const plan = buildTransferPlan(_extends({}, input, {
|
|
612
|
+
rows,
|
|
613
|
+
matches,
|
|
614
|
+
existing
|
|
615
|
+
}));
|
|
616
|
+
const findings = [];
|
|
617
|
+
const writing = plan.rows.filter((row)=>row.verdict === 'create' || row.verdict === 'update');
|
|
618
|
+
if (site.refusal) {
|
|
619
|
+
for (const row of writing)findings.push({
|
|
620
|
+
row: row.index,
|
|
621
|
+
detail: site.refusal,
|
|
622
|
+
refuse: true
|
|
623
|
+
});
|
|
624
|
+
return withTransferResourceFindings(plan, findings);
|
|
625
|
+
}
|
|
626
|
+
// What the page refuses about a rule on its own.
|
|
627
|
+
const accepted = [];
|
|
628
|
+
for (const row of writing){
|
|
629
|
+
const rule = finalRule(row.recordId ? existing.get(row.recordId) : null, row.diff);
|
|
630
|
+
const problem = ruleProblem(rule, site.host);
|
|
631
|
+
if (problem) {
|
|
632
|
+
findings.push(_extends({
|
|
633
|
+
row: row.index
|
|
634
|
+
}, problem, {
|
|
635
|
+
refuse: true
|
|
636
|
+
}));
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
accepted.push({
|
|
640
|
+
row,
|
|
641
|
+
rule
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
// What the page refuses against the other rules, row by row in file
|
|
645
|
+
// order over the site as the rows above leave it.
|
|
646
|
+
const liveRows = new Map(live.map(({ id, data })=>[
|
|
647
|
+
id,
|
|
648
|
+
ruleRow(id, data)
|
|
649
|
+
]));
|
|
650
|
+
const touched = new Set(accepted.flatMap(({ row })=>row.recordId ? [
|
|
651
|
+
row.recordId
|
|
652
|
+
] : []));
|
|
653
|
+
const ruleset = [
|
|
654
|
+
...liveRows.values()
|
|
655
|
+
].filter((rule)=>!touched.has(rule.$id));
|
|
656
|
+
const kept = [];
|
|
657
|
+
const keptByKey = new Map();
|
|
658
|
+
const keptByRecord = new Map();
|
|
659
|
+
for (const { row, rule } of accepted){
|
|
660
|
+
var _row_recordId, _keptByKey_get;
|
|
661
|
+
const candidate = {
|
|
662
|
+
$id: (_row_recordId = row.recordId) != null ? _row_recordId : `row:${row.index}`,
|
|
663
|
+
source: rule.source,
|
|
664
|
+
destination: rule.destination,
|
|
665
|
+
kind: rule.kind
|
|
666
|
+
};
|
|
667
|
+
const key = ruleKey(rule.kind, rule.source);
|
|
668
|
+
const repeated = (_keptByKey_get = keptByKey.get(key)) != null ? _keptByKey_get : row.recordId ? keptByRecord.get(row.recordId) : undefined;
|
|
669
|
+
let detail = null;
|
|
670
|
+
if (repeated !== undefined) detail = `Row ${repeated + 1} already redirects ${rule.source}`;
|
|
671
|
+
else if (findDuplicateRedirect(ruleset, candidate)) detail = `A rule for ${rule.source} already exists`;
|
|
672
|
+
else if (walkRedirectChain(ruleset, candidate).loop) {
|
|
673
|
+
detail = 'That destination chains back to this rule — a redirect loop';
|
|
674
|
+
}
|
|
675
|
+
if (detail) {
|
|
676
|
+
const fieldId = repeated !== undefined ? REDIRECT_FIELD.source : REDIRECT_FIELD.destination;
|
|
677
|
+
findings.push({
|
|
678
|
+
row: row.index,
|
|
679
|
+
detail,
|
|
680
|
+
fieldId,
|
|
681
|
+
value: String(rule[fieldId]),
|
|
682
|
+
refuse: true
|
|
683
|
+
});
|
|
684
|
+
// The rule it would have changed stays as it is.
|
|
685
|
+
const current = row.recordId ? liveRows.get(row.recordId) : undefined;
|
|
686
|
+
if (current) ruleset.push(current);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
ruleset.push(candidate);
|
|
690
|
+
kept.push({
|
|
691
|
+
row,
|
|
692
|
+
rule,
|
|
693
|
+
candidate
|
|
694
|
+
});
|
|
695
|
+
keptByKey.set(key, row.index);
|
|
696
|
+
if (row.recordId) keptByRecord.set(row.recordId, row.index);
|
|
697
|
+
}
|
|
698
|
+
// What a person should know about the rules that will be written, read
|
|
699
|
+
// over the site as the whole import leaves it.
|
|
700
|
+
for (const { row, rule, candidate } of kept){
|
|
701
|
+
const walk = walkRedirectChain(ruleset, candidate);
|
|
702
|
+
const path = [
|
|
703
|
+
rule.destination,
|
|
704
|
+
...walk.hops
|
|
705
|
+
].join(' → ');
|
|
706
|
+
if (walk.loop || walk.cycle) {
|
|
707
|
+
findings.push({
|
|
708
|
+
row: row.index,
|
|
709
|
+
detail: `Leads into a redirect loop: ${path}`,
|
|
710
|
+
fieldId: REDIRECT_FIELD.destination,
|
|
711
|
+
value: rule.destination
|
|
712
|
+
});
|
|
713
|
+
} else if (walk.hops.length) {
|
|
714
|
+
findings.push({
|
|
715
|
+
row: row.index,
|
|
716
|
+
detail: `Chains through another rule: ${path}`,
|
|
717
|
+
fieldId: REDIRECT_FIELD.destination,
|
|
718
|
+
value: rule.destination
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
if (isExternalRedirectDestination(rule.destination)) {
|
|
722
|
+
findings.push({
|
|
723
|
+
row: row.index,
|
|
724
|
+
detail: `Sends visitors to ${hostnameOf(rule.destination)}; importing approves it in your name`,
|
|
725
|
+
fieldId: REDIRECT_FIELD.destination,
|
|
726
|
+
value: rule.destination
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (redirectSourceIsLivePage(site.host.screens, rule.source)) {
|
|
730
|
+
findings.push({
|
|
731
|
+
row: row.index,
|
|
732
|
+
detail: `${rule.source} is a published page — the redirect takes precedence`,
|
|
733
|
+
fieldId: REDIRECT_FIELD.source,
|
|
734
|
+
value: rule.source
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// The plan's counter counts every rule document, soft-deleted ones
|
|
739
|
+
// included, as the create route counts them.
|
|
740
|
+
const remaining = Math.max(0, site.limit - documents.length);
|
|
741
|
+
return withCreateLimit(withTransferResourceFindings(plan, findings), remaining, site.limit);
|
|
742
|
+
},
|
|
743
|
+
async apply (ctx, chunk, writer) {
|
|
744
|
+
var _ref;
|
|
745
|
+
var _site_declared_externalDestination;
|
|
746
|
+
const site = await readSite(ctx);
|
|
747
|
+
const actor = ctx.actorUid;
|
|
748
|
+
const allowed = site.declared.fields;
|
|
749
|
+
const results = [];
|
|
750
|
+
const undo = [];
|
|
751
|
+
const paths = [];
|
|
752
|
+
const fail = (row, message, reason)=>({
|
|
753
|
+
result: _extends({
|
|
754
|
+
row: row.index,
|
|
755
|
+
outcome: 'failed'
|
|
756
|
+
}, row.recordId ? {
|
|
757
|
+
recordId: row.recordId
|
|
758
|
+
} : {}, reason ? {
|
|
759
|
+
reason
|
|
760
|
+
} : {}, {
|
|
761
|
+
message
|
|
762
|
+
}),
|
|
763
|
+
entry: undefined
|
|
764
|
+
});
|
|
765
|
+
const approvalField = (_ref = (_site_declared_externalDestination = site.declared.externalDestination) == null ? void 0 : _site_declared_externalDestination.approvedByField) != null ? _ref : REDIRECT_FIELD.approvedBy;
|
|
766
|
+
const stampFor = (destination)=>site.declared.externalDestination && isExternalRedirectDestination(String(destination != null ? destination : '')) ? actor : null;
|
|
767
|
+
const create = async (row)=>{
|
|
768
|
+
const rule = finalRule(null, row.diff);
|
|
769
|
+
const problem = ruleProblem(rule, site.host);
|
|
770
|
+
if (problem) return fail(row, problem.detail, 'resourceRule');
|
|
771
|
+
const fields = storedFields(rule, allowed);
|
|
772
|
+
const stamp = stampFor(fields['destination']);
|
|
773
|
+
const ref = rulesOf(site.hostId).doc(importedRedirectId(ctx.jobId, row.index));
|
|
774
|
+
const at = Timestamp.fromMillis(now());
|
|
775
|
+
// Counted and created in one transaction, as the create route does,
|
|
776
|
+
// so two imports (or an import and the page) cannot both take the
|
|
777
|
+
// last slot.
|
|
778
|
+
const outcome = await firestore.runTransaction(async (tx)=>{
|
|
779
|
+
if ((await tx.get(ref)).exists) return {
|
|
780
|
+
existed: true
|
|
781
|
+
};
|
|
782
|
+
if (site.declared.quotaKey && Number.isFinite(site.limit)) {
|
|
783
|
+
const used = (await tx.get(rulesOf(site.hostId).count())).data().count;
|
|
784
|
+
const quota = checkQuota(site.org, site.declared.quotaKey, used);
|
|
785
|
+
if (!quota.allowed) {
|
|
786
|
+
return {
|
|
787
|
+
refusal: `Your plan includes ${quota.limit} ${site.declared.label} — upgrade in Billing for more`
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
tx.create(ref, _extends({}, fields, stamp ? {
|
|
792
|
+
[approvalField]: stamp
|
|
793
|
+
} : {}, {
|
|
794
|
+
createdAt: at,
|
|
795
|
+
updatedAt: at,
|
|
796
|
+
createdBy: actor
|
|
797
|
+
}));
|
|
798
|
+
return {};
|
|
799
|
+
});
|
|
800
|
+
if (outcome.refusal) return fail(row, outcome.refusal, 'planLimit');
|
|
801
|
+
const written = _extends({}, fields, stamp ? {
|
|
802
|
+
[approvalField]: stamp
|
|
803
|
+
} : {});
|
|
804
|
+
if (!outcome.existed && deps.logCreated) {
|
|
805
|
+
await deps.logCreated(site.hostId, actor, ref.id, site.declared).catch(()=>undefined);
|
|
806
|
+
}
|
|
807
|
+
const path = announcedPath(fields);
|
|
808
|
+
if (path) paths.push(path);
|
|
809
|
+
const result = {
|
|
810
|
+
row: row.index,
|
|
811
|
+
outcome: 'created',
|
|
812
|
+
recordId: ref.id
|
|
813
|
+
};
|
|
814
|
+
const entry = {
|
|
815
|
+
row: row.index,
|
|
816
|
+
recordId: ref.id,
|
|
817
|
+
action: 'created',
|
|
818
|
+
written
|
|
819
|
+
};
|
|
820
|
+
return {
|
|
821
|
+
result,
|
|
822
|
+
entry
|
|
823
|
+
};
|
|
824
|
+
};
|
|
825
|
+
const update = async (row)=>{
|
|
826
|
+
const id = row.recordId;
|
|
827
|
+
const ref = rulesOf(site.hostId).doc(id);
|
|
828
|
+
const snapshot = await ref.get();
|
|
829
|
+
const data = snapshot.exists ? snapshot.data() : undefined;
|
|
830
|
+
if (!isLive(data)) return fail(row, 'This redirect was deleted after the dry run.', 'matchedRecordMissing');
|
|
831
|
+
const current = redirectRuleValues(id, data);
|
|
832
|
+
const rule = finalRule(current, row.diff);
|
|
833
|
+
const problem = ruleProblem(rule, site.host);
|
|
834
|
+
if (problem) return fail(row, problem.detail, 'resourceRule');
|
|
835
|
+
const fields = storedFields(rule, allowed);
|
|
836
|
+
const stamp = stampFor(fields['destination']);
|
|
837
|
+
const previous = {};
|
|
838
|
+
const written = {};
|
|
839
|
+
for (const fieldId of REDIRECT_WRITABLE_FIELDS){
|
|
840
|
+
var _current_fieldId;
|
|
841
|
+
if (!(fieldId in fields) || transferValuesEqual(current[fieldId], fields[fieldId])) continue;
|
|
842
|
+
previous[fieldId] = (_current_fieldId = current[fieldId]) != null ? _current_fieldId : null;
|
|
843
|
+
written[fieldId] = fields[fieldId];
|
|
844
|
+
}
|
|
845
|
+
if (!transferValuesEqual(current[approvalField], stamp)) {
|
|
846
|
+
var _current_approvalField;
|
|
847
|
+
previous[approvalField] = (_current_approvalField = current[approvalField]) != null ? _current_approvalField : null;
|
|
848
|
+
written[approvalField] = stamp;
|
|
849
|
+
}
|
|
850
|
+
if (!Object.keys(written).length) {
|
|
851
|
+
return {
|
|
852
|
+
result: {
|
|
853
|
+
row: row.index,
|
|
854
|
+
outcome: 'unchanged',
|
|
855
|
+
recordId: id
|
|
856
|
+
},
|
|
857
|
+
entry: undefined
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
// The page's edit: every field the form owns, the stamp set or
|
|
861
|
+
// removed, `updatedAt`, merged so `lastHitAt` and `deletedAt` stay.
|
|
862
|
+
await ref.set(_extends({}, fields, {
|
|
863
|
+
[approvalField]: stamp != null ? stamp : FieldValue.delete(),
|
|
864
|
+
updatedAt: Timestamp.fromMillis(now())
|
|
865
|
+
}), {
|
|
866
|
+
merge: true
|
|
867
|
+
});
|
|
868
|
+
for (const rulePath of [
|
|
869
|
+
announcedPath(fields),
|
|
870
|
+
announcedPath({
|
|
871
|
+
kind: text(data['kind']),
|
|
872
|
+
source: text(data['source'])
|
|
873
|
+
})
|
|
874
|
+
]){
|
|
875
|
+
if (rulePath) paths.push(rulePath);
|
|
876
|
+
}
|
|
877
|
+
const result = {
|
|
878
|
+
row: row.index,
|
|
879
|
+
outcome: 'updated',
|
|
880
|
+
recordId: id
|
|
881
|
+
};
|
|
882
|
+
const entry = {
|
|
883
|
+
row: row.index,
|
|
884
|
+
recordId: id,
|
|
885
|
+
action: 'updated',
|
|
886
|
+
previous,
|
|
887
|
+
written
|
|
888
|
+
};
|
|
889
|
+
return {
|
|
890
|
+
result,
|
|
891
|
+
entry
|
|
892
|
+
};
|
|
893
|
+
};
|
|
894
|
+
for (const row of chunk.rows){
|
|
895
|
+
const before = await writer.alreadyApplied(row.index);
|
|
896
|
+
if (before) {
|
|
897
|
+
results.push(before);
|
|
898
|
+
continue;
|
|
899
|
+
}
|
|
900
|
+
if (writer.timeLeftMs() < ROW_RESERVE_MS) break;
|
|
901
|
+
const outcome = site.refusal ? fail(row, site.refusal, 'resourceRule') : row.verdict === 'create' ? await create(row) : await update(row);
|
|
902
|
+
await writer.markApplied(outcome.result, outcome.entry);
|
|
903
|
+
results.push(outcome.result);
|
|
904
|
+
if (outcome.entry) undo.push(outcome.entry);
|
|
905
|
+
}
|
|
906
|
+
if (paths.length) await announce(site.hostId, [
|
|
907
|
+
...new Set(paths)
|
|
908
|
+
]).catch(()=>undefined);
|
|
909
|
+
return {
|
|
910
|
+
results,
|
|
911
|
+
undo
|
|
912
|
+
};
|
|
913
|
+
},
|
|
914
|
+
async revert (ctx, snapshot, decisions) {
|
|
915
|
+
var _site_host_memberRoles, _ref;
|
|
916
|
+
var _site_declared_externalDestination;
|
|
917
|
+
const site = await readSite(ctx);
|
|
918
|
+
// Undo writes rules too, so it needs the same role; a lapsed plan does
|
|
919
|
+
// not stop it, since a rule the plan no longer covers is not served.
|
|
920
|
+
const role = ctx.actorUid ? ((_site_host_memberRoles = site.host['memberRoles']) != null ? _site_host_memberRoles : {})[ctx.actorUid] : undefined;
|
|
921
|
+
if (!hostRoleCanPublish(role)) {
|
|
922
|
+
throw new Error('Undoing a redirect import needs a publishing role on this site — ask an editor or admin');
|
|
923
|
+
}
|
|
924
|
+
const approvalField = (_ref = (_site_declared_externalDestination = site.declared.externalDestination) == null ? void 0 : _site_declared_externalDestination.approvedByField) != null ? _ref : REDIRECT_FIELD.approvedBy;
|
|
925
|
+
const done = [];
|
|
926
|
+
const conflicts = [];
|
|
927
|
+
const paths = [];
|
|
928
|
+
for (const entry of snapshot.entries){
|
|
929
|
+
const ref = rulesOf(site.hostId).doc(entry.recordId);
|
|
930
|
+
const stored = await ref.get();
|
|
931
|
+
const data = stored.exists ? stored.data() : undefined;
|
|
932
|
+
const current = isLive(data) ? redirectRuleValues(entry.recordId, data) : null;
|
|
933
|
+
const step = planTransferUndo(entry, current);
|
|
934
|
+
if (step.action === 'conflict' && (decisions == null ? void 0 : decisions[entry.recordId]) !== 'revert') {
|
|
935
|
+
if ((decisions == null ? void 0 : decisions[entry.recordId]) === 'keep') {
|
|
936
|
+
done.push({
|
|
937
|
+
action: 'nothing',
|
|
938
|
+
recordId: entry.recordId,
|
|
939
|
+
why: 'alreadyReverted'
|
|
940
|
+
});
|
|
941
|
+
} else conflicts.push(step);
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
const before = current ? announcedPath({
|
|
945
|
+
kind: String(current['kind']),
|
|
946
|
+
source: String(current['source'])
|
|
947
|
+
}) : null;
|
|
948
|
+
if (step.action === 'delete' || step.action === 'conflict' && entry.action === 'created') {
|
|
949
|
+
await ref.delete();
|
|
950
|
+
if (before) paths.push(before);
|
|
951
|
+
} else if ((step.action === 'restore' || step.action === 'conflict') && current) {
|
|
952
|
+
const restored = _extends({}, current, step.values);
|
|
953
|
+
const patch = {};
|
|
954
|
+
for (const [fieldId, value] of Object.entries(step.values)){
|
|
955
|
+
patch[fieldId] = value === null || value === undefined ? FieldValue.delete() : value;
|
|
956
|
+
}
|
|
957
|
+
// A destination on the site never keeps an approval.
|
|
958
|
+
if (!isExternalRedirectDestination(text(restored['destination']))) patch[approvalField] = FieldValue.delete();
|
|
959
|
+
patch['updatedAt'] = Timestamp.fromMillis(now());
|
|
960
|
+
await ref.set(patch, {
|
|
961
|
+
merge: true
|
|
962
|
+
});
|
|
963
|
+
const after = announcedPath({
|
|
964
|
+
kind: String(restored['kind']),
|
|
965
|
+
source: String(restored['source'])
|
|
966
|
+
});
|
|
967
|
+
for (const path of [
|
|
968
|
+
before,
|
|
969
|
+
after
|
|
970
|
+
])if (path) paths.push(path);
|
|
971
|
+
}
|
|
972
|
+
done.push(step);
|
|
973
|
+
}
|
|
974
|
+
if (paths.length) await announce(site.hostId, [
|
|
975
|
+
...new Set(paths)
|
|
976
|
+
]).catch(()=>undefined);
|
|
977
|
+
return {
|
|
978
|
+
done,
|
|
979
|
+
conflicts
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
return resource;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
//# sourceMappingURL=redirects-transfer.js.map
|