@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.
@@ -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