@aglyn/tenant-data-admin 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 +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/ga4-measurement-protocol.js +41 -28
- package/src/lib/server/ga4-measurement-protocol.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +25 -4
- package/src/lib/server/host-visitor-records.js +39 -11
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +52 -36
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +11 -7
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/payment-fraud-signal.d.ts +49 -9
- package/src/lib/server/payment-fraud-signal.js +192 -48
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/render-monitor.d.ts +166 -0
- package/src/lib/server/render-monitor.js +412 -0
- package/src/lib/server/render-monitor.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +7 -0
- package/src/lib/server/risk-notice.js +6 -0
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +50 -9
- package/src/lib/server/web-risk.js +290 -155
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -3
- package/src/tenancy.js +5 -3
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/crm-email-activity.d.ts +0 -118
- package/src/lib/server/crm-email-activity.js +0 -148
- package/src/lib/server/crm-email-activity.js.map +0 -1
- package/src/lib/server/crm-next-activity.d.ts +0 -74
- package/src/lib/server/crm-next-activity.js +0 -194
- package/src/lib/server/crm-next-activity.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- package/src/lib/server/email-revenue-attribution.js.map +0 -1
|
@@ -15,18 +15,23 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { pipeline } from "node:stream/promises";
|
|
18
|
-
import { isLockdownActive, MEDIA_CDN_POSTER_PARAM, MEDIA_CDN_RENDITION_AUTO, MEDIA_CDN_RENDITION_PARAM, MEDIA_CDN_ROUTE, MEDIA_POSTER_OBJECT_SUFFIX, isSecurityClassLockdownReason, mediaPosterObjectPath, mediaRenditionObjectPath, normalizeHostLockdown, normalizeOrgLockdown, parseMediaRenditions } from "@aglyn/aglyn/server";
|
|
18
|
+
import { BANDWIDTH_CAP_RETRY_AFTER_SECONDS, bandwidthCapApplies, bandwidthCapEngaged, bandwidthCeilingDegradesHost, bandwidthCeilingMonthKey, isLockdownActive, MEDIA_BANDWIDTH_DAY_FIELD, MEDIA_CDN_POSTER_PARAM, MEDIA_CDN_RENDITION_AUTO, MEDIA_CDN_RENDITION_PARAM, MEDIA_CDN_ROUTE, MEDIA_CDN_VERSION_PARAM, MEDIA_POSTER_OBJECT_SUFFIX, MEDIA_VARIANT_ENCODER_VERSION, MEDIA_VARIANT_ENCODER_VERSION_FIELD, isSecurityClassLockdownReason, mediaDisplayObjectPath, mediaPosterObjectPath, mediaVariantEncoderVersionOf, mediaRenditionObjectPath, normalizeHostLockdown, normalizeOrgLockdown, parseMediaCdnVersionToken, parseMediaRenditions } from "@aglyn/aglyn/server";
|
|
19
19
|
// By path, and out of every barrel: see the module note in `media-cdn-scope`.
|
|
20
20
|
import { MEDIA_CDN_SEGMENT, mediaCdnScopeRefusal, parseMediaCdnScope } from "@aglyn/aglyn/app-utils/media-cdn-scope";
|
|
21
21
|
import { mediaDeliveryProvider } from "@aglyn/aglyn/plugin-manager/media-delivery-provider";
|
|
22
|
+
import { parseSiteIconSpec, SITE_ICON_BACKGROUND_PARAM, SITE_ICON_PARAM, SITE_ICON_VERSION_PARAM } from "@aglyn/aglyn/app-utils/site-icon-set";
|
|
22
23
|
import { analyticsDayExpiresAt } from "./analytics-retention.js";
|
|
23
24
|
import { firebaseAdmin } from "./firebase-admin.js";
|
|
25
|
+
import { recordMediaServe } from "./media-serve-count.js";
|
|
24
26
|
import { getPlatformLockdown } from "./lockdown.js";
|
|
27
|
+
import { engageMediaBandwidthCap, mediaBandwidthEvaluationDue } from "./media-bandwidth-cap.js";
|
|
25
28
|
import { mediaCdnRateLimitRefusal } from "./media-cdn-rate-limit.js";
|
|
26
29
|
import { mediaDeliveryOrgIdFor, mediaDeliveryRedirect } from "./media-delivery.js";
|
|
30
|
+
import { mediaDeliveryCopiesStale, regenerateMediaDeliveryCopies } from "./media-delivery-regeneration.js";
|
|
27
31
|
import { getMediaQuarantine } from "./media-quarantine.js";
|
|
28
32
|
import { verifyMediaAccess } from "./media-signing.js";
|
|
29
33
|
import { mediaStoragePathInScope } from "./media-storage-path.js";
|
|
34
|
+
import { serveMediaCdnIcon } from "./media-cdn-icon.js";
|
|
30
35
|
/**
|
|
31
36
|
* Variant widths generated at upload (AGL-175).
|
|
32
37
|
*
|
|
@@ -67,13 +72,19 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
|
|
|
67
72
|
// redirect must name the SAME representation the caller asked for, or a
|
|
68
73
|
// stale content pin on a poster URL lands the browser on the master video
|
|
69
74
|
// (AGL-2743).
|
|
75
|
+
// `icon`, `bg` and `v` for the same reason: a site icon (AGL-3484) behind a
|
|
76
|
+
// stale content pin must stay the icon, not become the full-size original.
|
|
70
77
|
for (const key of [
|
|
71
78
|
'w',
|
|
72
79
|
'poster',
|
|
73
80
|
'r',
|
|
74
81
|
'download',
|
|
82
|
+
SITE_ICON_PARAM,
|
|
83
|
+
SITE_ICON_BACKGROUND_PARAM,
|
|
84
|
+
SITE_ICON_VERSION_PARAM,
|
|
75
85
|
'exp',
|
|
76
|
-
'sig'
|
|
86
|
+
'sig',
|
|
87
|
+
'aud'
|
|
77
88
|
]){
|
|
78
89
|
const raw = query[key];
|
|
79
90
|
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
@@ -141,6 +152,59 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
|
|
|
141
152
|
* the cap and was never edge-cached — the mangling reproduced on a 186 KB
|
|
142
153
|
* asset precisely because small ones are the ones that get cached.
|
|
143
154
|
*/ export const MEDIA_CDN_STABLE_EDGE_BYPASS_CACHE_CONTROL = 'private, max-age=60';
|
|
155
|
+
/**
|
|
156
|
+
* The VERSIONED stable URL's policy (AGL-3485): `?v={contentHash}.{encoder}`
|
|
157
|
+
* on the stable path, answered while the token names the asset's current
|
|
158
|
+
* bytes and current variants.
|
|
159
|
+
*
|
|
160
|
+
* The browser keeps it for a year, `immutable`, which is the whole point: the
|
|
161
|
+
* 60-second window above made every repeat view of every published image a
|
|
162
|
+
* conditional request, and Lighthouse reports that policy against each one.
|
|
163
|
+
* It is safe for the reason the content-hashed form never was in a page
|
|
164
|
+
* (AGL-2798): the page names this URL only while the token is current, the
|
|
165
|
+
* composition reads the token off the media document on every render, and a
|
|
166
|
+
* replace or a regeneration therefore moves the page to a new URL instead of
|
|
167
|
+
* leaving it on a pinned copy of the old bytes.
|
|
168
|
+
*
|
|
169
|
+
* The EDGE keeps the stable URL's hour and its revalidation, deliberately not
|
|
170
|
+
* a year: the edge is the copy a takedown or a lockdown has to outrun for
|
|
171
|
+
* every new visitor (`media-takedown-reach.ts`), and an hour is the bound the
|
|
172
|
+
* product states for it. Other visitors still get the edge's copy within that
|
|
173
|
+
* hour, which is what `s-maxage` is for. Images only — every other type keeps
|
|
174
|
+
* the stable policy, for the AGL-1515 reasons above.
|
|
175
|
+
*/ export const MEDIA_CDN_VERSIONED_CACHE_CONTROL = 'public, max-age=31536000, s-maxage=3600, stale-while-revalidate=86400, immutable';
|
|
176
|
+
/**
|
|
177
|
+
* Whether a versioned request may be answered with
|
|
178
|
+
* {@link MEDIA_CDN_VERSIONED_CACHE_CONTROL} (AGL-3485).
|
|
179
|
+
*
|
|
180
|
+
* Only when everything the token promises is true of what is being served:
|
|
181
|
+
*
|
|
182
|
+
* - the token's content hash is the document's CURRENT one. A stale token is
|
|
183
|
+
* served the current bytes under the stable URL's short policy — a stale
|
|
184
|
+
* page shows the new picture, and nothing pins it;
|
|
185
|
+
* - the representation is the original or a `?w=` variant. A poster or a
|
|
186
|
+
* rendition keeps its own policy;
|
|
187
|
+
* - for a `?w=` request, the variant itself is what is served. A width the
|
|
188
|
+
* asset has no variant for falls back to another representation, and a
|
|
189
|
+
* fallback is not something to keep for a year: the variant may be
|
|
190
|
+
* generated later;
|
|
191
|
+
* - and the asset's delivery copies were made by the encoder generation the
|
|
192
|
+
* token names, which is the one this code runs. The media document records
|
|
193
|
+
* which generation made them (`MEDIA_VARIANT_ENCODER_VERSION_FIELD`, absent
|
|
194
|
+
* meaning the first). A page rendered after the encoder moved on names the
|
|
195
|
+
* new generation before the asset has been regenerated; answering that
|
|
196
|
+
* request with the old encode for a year would hide the regeneration behind
|
|
197
|
+
* the very URL that was minted to reach it. Asked of every request, not
|
|
198
|
+
* only `?w=`, because the encoder makes what the bare URL serves too.
|
|
199
|
+
*/ export function mediaCdnVersionIsCurrent(options) {
|
|
200
|
+
const { token, currentHash } = options;
|
|
201
|
+
if (!token || !currentHash || token.contentHash !== currentHash) return false;
|
|
202
|
+
if (options.otherRepresentation) return false;
|
|
203
|
+
if (options.widthRequested && !options.variantServed) return false;
|
|
204
|
+
const recorded = options.documentEncoderVersion;
|
|
205
|
+
const generation = typeof recorded === 'number' && Number.isInteger(recorded) && recorded > 0 ? recorded : 1;
|
|
206
|
+
return token.encoderVersion === MEDIA_VARIANT_ENCODER_VERSION && generation === token.encoderVersion;
|
|
207
|
+
}
|
|
144
208
|
/** The immutable content-hashed URL's policy for edge-cacheable (image) types. */ export const MEDIA_CDN_IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
145
209
|
/**
|
|
146
210
|
* The immutable URL's policy for edge-bypassing types (AGL-1515): the
|
|
@@ -308,16 +372,22 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
|
|
|
308
372
|
*
|
|
309
373
|
* That lookup is one BatchGetDocuments at org scope, and two at host scope
|
|
310
374
|
* — `hosts` and `hostIndex` batched together, then the owning org, whose id
|
|
311
|
-
* is what `hostIndex` returns and so cannot join the batch.
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
* against production, projecting the three reads and
|
|
315
|
-
* took the host branch from 3 round trips and 2,964 B to
|
|
375
|
+
* is what `hostIndex` returns and so cannot join the batch. The host and
|
|
376
|
+
* index reads are projected to {@link SUSPENSION_FIELDS} plus the two fields
|
|
377
|
+
* the bandwidth verdict needs off them: the host document is the largest in
|
|
378
|
+
* the product. Measured against production, projecting the three reads and
|
|
379
|
+
* batching two of them took the host branch from 3 round trips and 2,964 B to
|
|
380
|
+
* 2 and 34 B. The ORG read is whole since AGL-3474, because the bandwidth
|
|
381
|
+
* verdict asks the org's plan, and the plan is resolved from more fields than
|
|
382
|
+
* a projection could promise to keep in step with — a mask missing one would
|
|
383
|
+
* read a paying org as Free and pause its video. Still one read per scope
|
|
384
|
+
* per TTL.
|
|
316
385
|
*
|
|
317
386
|
* **Staleness bound, stated rather than hidden:** a warm origin refuses
|
|
318
387
|
* within ≤15s of the org-doc write (the platform panic number). What the
|
|
319
388
|
* origin cannot reach: browsers hold the stable URL up to 60s
|
|
320
|
-
* (`max-age=60`)
|
|
389
|
+
* (`max-age=60`), and a versioned image URL a published page names for a
|
|
390
|
+
* year in a browser that already fetched it (AGL-3485); Vercel's edge holds image responses up to `s-maxage=3600`
|
|
321
391
|
* (+ one stale serve while revalidating) — so an already-edge-cached image
|
|
322
392
|
* URL can serve up to ~1h into a lock; non-image types are `private`
|
|
323
393
|
* (AGL-1515) and never edge-held. The immutable content-hashed form is
|
|
@@ -338,6 +408,12 @@ const SEGMENT = MEDIA_CDN_SEGMENT;
|
|
|
338
408
|
* stops only new origin fetches (AGL-1615). Bytes already cached anywhere
|
|
339
409
|
* remain out of reach of both.
|
|
340
410
|
*/ const MEDIA_CDN_LOCK_TTL_MS = 15000;
|
|
411
|
+
/** The verdict when nothing could be read: serve, the lockdown core's posture. */ const OPEN_SCOPE_VERDICT = {
|
|
412
|
+
locked: false,
|
|
413
|
+
bandwidthPaused: false,
|
|
414
|
+
capApplies: false,
|
|
415
|
+
orgId: null
|
|
416
|
+
};
|
|
341
417
|
const lockCache = new Map();
|
|
342
418
|
const lockPending = new Map();
|
|
343
419
|
/**
|
|
@@ -369,38 +445,65 @@ const lockPending = new Map();
|
|
|
369
445
|
'suspendedUntilMs'
|
|
370
446
|
];
|
|
371
447
|
/** Which host owns the asset's scope — the only field read off `hostIndex`. */ const HOST_INDEX_ORG_FIELD = 'orgId';
|
|
448
|
+
/** The site's abuse-ceiling stamp (AGL-2155), read beside its lockdown. */ const HOST_BANDWIDTH_CEILING_FIELD = 'bandwidthCeiling';
|
|
372
449
|
/** The `suspended*` field family off a snapshot, for the normalizers. */ const suspensionCarrier = (snapshot)=>Object.fromEntries(SUSPENSION_FIELDS.map((field)=>[
|
|
373
450
|
field,
|
|
374
451
|
snapshot.get(field)
|
|
375
452
|
]));
|
|
376
|
-
/**
|
|
453
|
+
/**
|
|
454
|
+
* The verdict an org document gives: its lockdown and, when it is not locked,
|
|
455
|
+
* whether its bandwidth stops video and files. `host` is the site's projected
|
|
456
|
+
* document for a host-library scope, whose abuse ceiling (AGL-2155) pauses the
|
|
457
|
+
* site's own media as it pauses the site's pages; an org library belongs to
|
|
458
|
+
* no site, so only the org-wide cap reaches it.
|
|
459
|
+
*/ function orgScopeVerdict(org, orgId, host, nowMs) {
|
|
460
|
+
var _org_data;
|
|
461
|
+
if (lockdownStopsMediaDelivery(normalizeOrgLockdown(suspensionCarrier(org)), nowMs)) {
|
|
462
|
+
return _extends({}, OPEN_SCOPE_VERDICT, {
|
|
463
|
+
locked: true,
|
|
464
|
+
orgId
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const billing = (_org_data = org.data()) != null ? _org_data : null;
|
|
468
|
+
const now = new Date(nowMs);
|
|
469
|
+
return {
|
|
470
|
+
locked: false,
|
|
471
|
+
bandwidthPaused: bandwidthCapEngaged(billing, now) || host !== null && bandwidthCeilingDegradesHost({
|
|
472
|
+
[HOST_BANDWIDTH_CEILING_FIELD]: host.get(HOST_BANDWIDTH_CEILING_FIELD)
|
|
473
|
+
}, bandwidthCeilingMonthKey(now), billing),
|
|
474
|
+
// A missing org document resolves as Free, but there is no org to total
|
|
475
|
+
// or to stamp, so nothing is evaluated for it.
|
|
476
|
+
capApplies: billing !== null && bandwidthCapApplies(billing),
|
|
477
|
+
orgId
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/** TTL-cached: what the documents covering `scope` say about serving it. */ async function mediaCdnScopeVerdict(scope) {
|
|
377
481
|
const key = `${scope.isOrg ? 'org' : 'host'}:${scope.scopeId}`;
|
|
378
482
|
const cached = lockCache.get(key);
|
|
379
483
|
if (cached && Date.now() - cached.at < MEDIA_CDN_LOCK_TTL_MS) {
|
|
380
|
-
return cached.
|
|
484
|
+
return cached.verdict;
|
|
381
485
|
}
|
|
382
486
|
let pending = lockPending.get(key);
|
|
383
487
|
if (!pending) {
|
|
384
488
|
pending = (async ()=>{
|
|
385
|
-
let
|
|
489
|
+
let verdict = OPEN_SCOPE_VERDICT;
|
|
386
490
|
try {
|
|
387
491
|
const nowMs = Date.now();
|
|
388
492
|
// Platform first: cached, and a platform security lock is the panic
|
|
389
493
|
// button — asset delivery is part of what it stops.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
494
|
+
if (lockdownStopsMediaDelivery(await getPlatformLockdown(), nowMs)) {
|
|
495
|
+
verdict = _extends({}, OPEN_SCOPE_VERDICT, {
|
|
496
|
+
locked: true
|
|
497
|
+
});
|
|
498
|
+
} else if (scope.isOrg) {
|
|
499
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
393
500
|
// Org forms (`org:{orgId}` and `org:{orgId}:{hostId}`): the org
|
|
394
501
|
// doc governs. The context host's own lock is not consulted — a
|
|
395
502
|
// suspended HOST's pages 503 already, and which sites may USE an
|
|
396
503
|
// org asset is `visibleTo`'s question, not the lock's.
|
|
397
|
-
const [org] = await firestore.getAll(firestore.collection('orgs').doc(scope.scopeId)
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
]
|
|
401
|
-
});
|
|
402
|
-
blocked = lockdownStopsMediaDelivery(normalizeOrgLockdown(suspensionCarrier(org)), nowMs);
|
|
403
|
-
} else if (!blocked) {
|
|
504
|
+
const [org] = await firestore.getAll(firestore.collection('orgs').doc(scope.scopeId));
|
|
505
|
+
verdict = orgScopeVerdict(org, scope.scopeId, null, nowMs);
|
|
506
|
+
} else {
|
|
404
507
|
// Host-library form: the host's own lock, and the OWNING org's —
|
|
405
508
|
// an org lock never stamps host docs (AGL-1506), so a host-only
|
|
406
509
|
// read would silently miss the very lock this issue is about.
|
|
@@ -410,33 +513,35 @@ const lockPending = new Map();
|
|
|
410
513
|
// BatchGetDocuments round trips where the batch is one. The org
|
|
411
514
|
// read below cannot join them — its id is what `hostIndex`
|
|
412
515
|
// returns — so two is the floor for this branch, not three.
|
|
516
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
413
517
|
const [host, hostIndex] = await firestore.getAll(firestore.collection('hosts').doc(scope.scopeId), firestore.collection('hostIndex').doc(scope.scopeId), {
|
|
414
518
|
fieldMask: [
|
|
415
519
|
...SUSPENSION_FIELDS,
|
|
416
|
-
HOST_INDEX_ORG_FIELD
|
|
520
|
+
HOST_INDEX_ORG_FIELD,
|
|
521
|
+
HOST_BANDWIDTH_CEILING_FIELD
|
|
417
522
|
]
|
|
418
523
|
});
|
|
419
|
-
blocked = lockdownStopsMediaDelivery(normalizeHostLockdown(suspensionCarrier(host)), nowMs);
|
|
420
524
|
const orgId = hostIndex.get(HOST_INDEX_ORG_FIELD);
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
...SUSPENSION_FIELDS
|
|
425
|
-
]
|
|
525
|
+
if (lockdownStopsMediaDelivery(normalizeHostLockdown(suspensionCarrier(host)), nowMs)) {
|
|
526
|
+
verdict = _extends({}, OPEN_SCOPE_VERDICT, {
|
|
527
|
+
locked: true
|
|
426
528
|
});
|
|
427
|
-
|
|
529
|
+
} else if (typeof orgId === 'string' && orgId) {
|
|
530
|
+
const [org] = await firestore.getAll(firestore.collection('orgs').doc(orgId));
|
|
531
|
+
verdict = orgScopeVerdict(org, orgId, host, nowMs);
|
|
428
532
|
}
|
|
429
533
|
}
|
|
430
534
|
} catch (unused) {
|
|
431
535
|
// Fail open — the lockdown core's posture (see lockdown.ts): an
|
|
432
|
-
// unreachable Firestore is an outage, not a lockdown
|
|
433
|
-
|
|
536
|
+
// unreachable Firestore is an outage, not a lockdown, and it is not a
|
|
537
|
+
// spent band either.
|
|
538
|
+
verdict = OPEN_SCOPE_VERDICT;
|
|
434
539
|
}
|
|
435
540
|
lockCache.set(key, {
|
|
436
541
|
at: Date.now(),
|
|
437
|
-
|
|
542
|
+
verdict
|
|
438
543
|
});
|
|
439
|
-
return
|
|
544
|
+
return verdict;
|
|
440
545
|
})().finally(()=>{
|
|
441
546
|
lockPending.delete(key);
|
|
442
547
|
});
|
|
@@ -444,6 +549,9 @@ const lockPending = new Map();
|
|
|
444
549
|
}
|
|
445
550
|
return pending;
|
|
446
551
|
}
|
|
552
|
+
/** TTL-cached: does any lockdown covering `scope` stop delivery? */ async function mediaCdnScopeLocked(scope) {
|
|
553
|
+
return (await mediaCdnScopeVerdict(scope)).locked;
|
|
554
|
+
}
|
|
447
555
|
/**
|
|
448
556
|
* THE delivery-policy seam: "may this asset be served at all?" — one
|
|
449
557
|
* question, one function, consulted once per request before any caching
|
|
@@ -493,6 +601,39 @@ const lockPending = new Map();
|
|
|
493
601
|
const normalized = String(raw != null ? raw : '').toLowerCase();
|
|
494
602
|
return normalized === '1' || normalized === 'true';
|
|
495
603
|
}
|
|
604
|
+
/**
|
|
605
|
+
* The display copy a media document records (AGL-3486), or null when it has
|
|
606
|
+
* none. Only the type is needed to serve it — the object path is derived — and
|
|
607
|
+
* a record whose type is not an image is treated as no record: the display
|
|
608
|
+
* copy is always an image, and serving its object under another type would be
|
|
609
|
+
* a document field choosing a response's `Content-Type`.
|
|
610
|
+
*/ export function mediaCdnDisplayCopy(value) {
|
|
611
|
+
if (!value || typeof value !== 'object') return null;
|
|
612
|
+
const contentType = value.contentType;
|
|
613
|
+
if (typeof contentType !== 'string' || !/^image\/[a-z0-9.+-]+$/.test(contentType)) {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
contentType
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Runs `task` once the response has been sent, through Next's `after()`.
|
|
622
|
+
* Required when first asked for rather than imported, for the reason
|
|
623
|
+
* `capture-email-check.ts` gives; false where there is no request to run
|
|
624
|
+
* after — a spec, a script — and the task is then not run at all.
|
|
625
|
+
*/ function scheduleAfterResponse(task) {
|
|
626
|
+
try {
|
|
627
|
+
const loaded = require('next/server');
|
|
628
|
+
if (typeof (loaded == null ? void 0 : loaded.after) !== 'function') return false;
|
|
629
|
+
loaded.after(()=>task().catch((error)=>{
|
|
630
|
+
console.error('[media-cdn] after-response task failed', error);
|
|
631
|
+
}));
|
|
632
|
+
return true;
|
|
633
|
+
} catch (unused) {
|
|
634
|
+
return false;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
496
637
|
/**
|
|
497
638
|
* Whether `?poster=1` was asked for (AGL-2743).
|
|
498
639
|
*
|
|
@@ -702,6 +843,20 @@ export function parseMediaCdnRange(header, size) {
|
|
|
702
843
|
}
|
|
703
844
|
res.setHeader('Cache-Control', 'no-store');
|
|
704
845
|
}
|
|
846
|
+
/**
|
|
847
|
+
* Note `bytes` of counted media against the scope's org on this instance, and
|
|
848
|
+
* when that makes it due, total the org's month and engage the Free cap
|
|
849
|
+
* (AGL-3474; see `media-bandwidth-cap.ts`).
|
|
850
|
+
*
|
|
851
|
+
* Only for an org whose plan the cap stops and that is not already paused,
|
|
852
|
+
* both read off the cached scope verdict, so a paying org's delivery reads
|
|
853
|
+
* nothing for it. Never rejects.
|
|
854
|
+
*/ async function mediaCdnBandwidthEvaluation(scope, bytes) {
|
|
855
|
+
const verdict = await mediaCdnScopeVerdict(scope);
|
|
856
|
+
if (!verdict.capApplies || verdict.bandwidthPaused || !verdict.orgId) return;
|
|
857
|
+
if (!mediaBandwidthEvaluationDue(verdict.orgId, bytes)) return;
|
|
858
|
+
await engageMediaBandwidthCap(verdict.orgId);
|
|
859
|
+
}
|
|
705
860
|
/**
|
|
706
861
|
* CDN media delivery (AGL-175 / AGL-829). Two URL shapes resolve the same
|
|
707
862
|
* asset by `mediaId`, so delivery never depends on the object's storage
|
|
@@ -905,7 +1060,8 @@ export function parseMediaCdnRange(header, size) {
|
|
|
905
1060
|
var _req_query_exp, _req_query_sig;
|
|
906
1061
|
const signed = verifyMediaAccess(scopeSegment, mediaId, {
|
|
907
1062
|
exp: Number((_req_query_exp = req.query['exp']) != null ? _req_query_exp : 0),
|
|
908
|
-
sig: String((_req_query_sig = req.query['sig']) != null ? _req_query_sig : '')
|
|
1063
|
+
sig: String((_req_query_sig = req.query['sig']) != null ? _req_query_sig : ''),
|
|
1064
|
+
aud: req.query['aud'] === 'team' ? 'team' : undefined
|
|
909
1065
|
});
|
|
910
1066
|
setCacheControl('private, no-store');
|
|
911
1067
|
if (!signed) {
|
|
@@ -915,6 +1071,19 @@ export function parseMediaCdnRange(header, size) {
|
|
|
915
1071
|
return;
|
|
916
1072
|
}
|
|
917
1073
|
}
|
|
1074
|
+
/*
|
|
1075
|
+
* WHAT COUNTS TOWARD THE BANDWIDTH BAND (AGL-3474).
|
|
1076
|
+
*
|
|
1077
|
+
* Every type the edge never holds, because each such response leaves from
|
|
1078
|
+
* origin. A public image is the exception: it is inside the page weight
|
|
1079
|
+
* the band already measures, and the edge serves most of it. A PRIVATE
|
|
1080
|
+
* image is not — it cannot be placed on a page, and `no-store` sends every
|
|
1081
|
+
* request to origin — so it counts like a file. The one serve that never
|
|
1082
|
+
* counts is the workspace previewing its own private library in the
|
|
1083
|
+
* console: its signature names the team (`aud=team`, inside the HMAC, so
|
|
1084
|
+
* a visitor's link cannot claim it).
|
|
1085
|
+
*/ const teamPreview = isPrivate && req.query['aud'] === 'team';
|
|
1086
|
+
const countsTowardBand = (type)=>!teamPreview && (isPrivate || !mediaCdnEdgeCacheable(type));
|
|
918
1087
|
/**
|
|
919
1088
|
* A stale hash on the immutable form REDIRECTS to the stable URL
|
|
920
1089
|
* (AGL-2685). It used to 404.
|
|
@@ -953,6 +1122,44 @@ export function parseMediaCdnRange(header, size) {
|
|
|
953
1122
|
res.status(302).end();
|
|
954
1123
|
return;
|
|
955
1124
|
}
|
|
1125
|
+
/*
|
|
1126
|
+
* A site icon (AGL-3484): one size of the favicon, touch icon or manifest
|
|
1127
|
+
* set, drawn from this asset's bytes. Past every gate above and ahead of
|
|
1128
|
+
* every other representation, which it excludes — see `media-cdn-icon.ts`.
|
|
1129
|
+
*/ const iconSpec = parseSiteIconSpec(req.query[SITE_ICON_PARAM]);
|
|
1130
|
+
if (iconSpec) {
|
|
1131
|
+
const iconBase = `${isOrg ? 'orgs' : 'hosts'}/${scopeId}`;
|
|
1132
|
+
const iconFile = firebaseAdmin.app().storage().bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']).file(mediaStoragePathInScope({
|
|
1133
|
+
storagePath: snapshot.get('storagePath'),
|
|
1134
|
+
base: iconBase,
|
|
1135
|
+
mediaId
|
|
1136
|
+
}));
|
|
1137
|
+
await serveMediaCdnIcon({
|
|
1138
|
+
req,
|
|
1139
|
+
res,
|
|
1140
|
+
spec: iconSpec,
|
|
1141
|
+
source: {
|
|
1142
|
+
contentType: snapshot.get('contentType'),
|
|
1143
|
+
contentHash: currentHash,
|
|
1144
|
+
stablePath: `${MEDIA_CDN_ROUTE}/${scopeSegment}/${mediaId}`,
|
|
1145
|
+
file: iconFile
|
|
1146
|
+
},
|
|
1147
|
+
setCacheControl,
|
|
1148
|
+
cacheControl: {
|
|
1149
|
+
stable: MEDIA_CDN_STABLE_CACHE_CONTROL,
|
|
1150
|
+
immutable: MEDIA_CDN_IMMUTABLE_CACHE_CONTROL
|
|
1151
|
+
},
|
|
1152
|
+
rateLimit: ()=>{
|
|
1153
|
+
var _req_socket;
|
|
1154
|
+
return mediaCdnRateLimitRefusal({
|
|
1155
|
+
headers: req.headers,
|
|
1156
|
+
remoteAddress: (_req_socket = req.socket) == null ? void 0 : _req_socket.remoteAddress,
|
|
1157
|
+
rateClass: 'image'
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
956
1163
|
const width = Number((_req_query_w = req.query['w']) != null ? _req_query_w : 0);
|
|
957
1164
|
const variants = (_snapshot_get2 = snapshot.get('variants')) != null ? _snapshot_get2 : [];
|
|
958
1165
|
/*
|
|
@@ -1031,6 +1238,31 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1031
1238
|
res.setHeader('Vary', 'Accept');
|
|
1032
1239
|
}
|
|
1033
1240
|
const useVariant = !usePoster && !rendition && Boolean(width) && variants.includes(width);
|
|
1241
|
+
// Read only here, past every gate — a refusal above returns before the
|
|
1242
|
+
// parameter is ever looked at, so `?download=1` can never be the reason
|
|
1243
|
+
// a response happens (AGL-1411).
|
|
1244
|
+
const download = wantsMediaDownload(req.query['download']);
|
|
1245
|
+
/*
|
|
1246
|
+
* THE DISPLAY COPY (AGL-3486).
|
|
1247
|
+
*
|
|
1248
|
+
* An image whose original is larger than the delivery edge, carries EXIF,
|
|
1249
|
+
* XMP or IPTC, or leans on an EXIF orientation has a display copy: the
|
|
1250
|
+
* same format, downscaled, upright and stripped. It answers every INLINE
|
|
1251
|
+
* request that is not a variant — the bare URL, and a `?w=` the asset has
|
|
1252
|
+
* no variant for, which used to fall back to the original. That is the
|
|
1253
|
+
* request a link preview, an email client and a CSS background make, and
|
|
1254
|
+
* it used to hand them a 6 MB photo with the GPS position it was taken at.
|
|
1255
|
+
*
|
|
1256
|
+
* `?download=1` still serves the ORIGINAL, byte for byte: Download file is
|
|
1257
|
+
* a promise the library makes, and it is the one request that asks for the
|
|
1258
|
+
* file rather than for a picture of it.
|
|
1259
|
+
*/ const display = mediaCdnDisplayCopy(snapshot.get('display'));
|
|
1260
|
+
const useDisplay = !usePoster && !rendition && !useVariant && !download && display !== null;
|
|
1261
|
+
// The generation that made the variant or display copy, in its validator:
|
|
1262
|
+
// a regeneration changes the bytes behind the same URL, and a browser
|
|
1263
|
+
// revalidating the old encode must be sent the new one, not a 304.
|
|
1264
|
+
const encoderVersion = mediaVariantEncoderVersionOf(snapshot.get(MEDIA_VARIANT_ENCODER_VERSION_FIELD));
|
|
1265
|
+
const encoderTag = encoderVersion > 1 ? `-e${encoderVersion}` : '';
|
|
1034
1266
|
/**
|
|
1035
1267
|
* The ETag's representation tag, and the cache key's conscience.
|
|
1036
1268
|
*
|
|
@@ -1040,11 +1272,7 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1040
1272
|
* revalidates. Between an image variant and its original that is a wrong
|
|
1041
1273
|
* size; between a poster and its video it is a 40 KB still standing in
|
|
1042
1274
|
* for a 40 MB film, or the reverse.
|
|
1043
|
-
*/ const representation = usePoster ? `-poster${usePosterVariant ? `-w${width}` : ''}` : rendition ? `-r${rendition.key}` : useVariant ? `-w${width}` : '';
|
|
1044
|
-
// Read only here, past every gate — a refusal above returns before the
|
|
1045
|
-
// parameter is ever looked at, so `?download=1` can never be the reason
|
|
1046
|
-
// a response happens (AGL-1411).
|
|
1047
|
-
const download = wantsMediaDownload(req.query['download']);
|
|
1275
|
+
*/ const representation = usePoster ? `-poster${usePosterVariant ? `-w${width}` : ''}` : rendition ? `-r${rendition.key}` : useVariant ? `-w${width}${encoderTag}` : useDisplay ? `-d${encoderTag}` : '';
|
|
1048
1276
|
// Stable URL: revalidate against an ETag so a replaced asset is picked
|
|
1049
1277
|
// up (a conditional GET returns 304 while the content is unchanged).
|
|
1050
1278
|
//
|
|
@@ -1061,7 +1289,20 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1061
1289
|
// type (a variant serve is always `image/webp`) because the 304 exit
|
|
1062
1290
|
// below runs before the Storage metadata read; re-derived from the
|
|
1063
1291
|
// authoritative served type once metadata is in hand.
|
|
1064
|
-
|
|
1292
|
+
//
|
|
1293
|
+
// A VERSIONED request (AGL-3485) whose token is current is the one image
|
|
1294
|
+
// response a browser may keep for a year; see
|
|
1295
|
+
// `MEDIA_CDN_VERSIONED_CACHE_CONTROL`. The hashed path form keeps its own
|
|
1296
|
+
// policy below, so a `?v=` on it is inert.
|
|
1297
|
+
const versionCurrent = !hashed && mediaCdnVersionIsCurrent({
|
|
1298
|
+
token: parseMediaCdnVersionToken(req.query[MEDIA_CDN_VERSION_PARAM]),
|
|
1299
|
+
currentHash,
|
|
1300
|
+
widthRequested: Boolean(width),
|
|
1301
|
+
variantServed: useVariant,
|
|
1302
|
+
otherRepresentation: usePoster || Boolean(rendition),
|
|
1303
|
+
documentEncoderVersion: snapshot.get(MEDIA_VARIANT_ENCODER_VERSION_FIELD)
|
|
1304
|
+
});
|
|
1305
|
+
const stableCacheControlFor = (type)=>mediaCdnEdgeCacheable(type) ? versionCurrent ? MEDIA_CDN_VERSIONED_CACHE_CONTROL : MEDIA_CDN_STABLE_CACHE_CONTROL : MEDIA_CDN_STABLE_EDGE_BYPASS_CACHE_CONTROL;
|
|
1065
1306
|
// A poster is `image/webp` and therefore edge-cacheable through the rule
|
|
1066
1307
|
// that already exists — which is the whole delivery argument for it
|
|
1067
1308
|
// (AGL-2743). `mediaCdnEdgeCacheable` is untouched: nothing here relaxes
|
|
@@ -1071,7 +1312,36 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1071
1312
|
// exactly the size class Vercel's 10 MB cap admits, so edge-caching one
|
|
1072
1313
|
// would maximize exposure to the very hybrid AGL-1515 recorded rather
|
|
1073
1314
|
// than avoid it.
|
|
1074
|
-
const docServedType = usePoster || useVariant ? 'image/webp' : rendition ? rendition.contentType : snapshot.get('contentType');
|
|
1315
|
+
const docServedType = usePoster || useVariant ? 'image/webp' : rendition ? rendition.contentType : useDisplay ? display.contentType : snapshot.get('contentType');
|
|
1316
|
+
/*
|
|
1317
|
+
* THE BANDWIDTH BAND (AGL-3474).
|
|
1318
|
+
*
|
|
1319
|
+
* Video and files count against the org's bandwidth allowance (see
|
|
1320
|
+
* `media-bandwidth.ts`), so they stop where the band stops: when the Free
|
|
1321
|
+
* cap is engaged for the owning org, or the site's abuse ceiling degrades
|
|
1322
|
+
* it — the same two predicates that pause the site's pages, read off the
|
|
1323
|
+
* scope documents the lockdown verdict above already holds. No read is
|
|
1324
|
+
* added.
|
|
1325
|
+
*
|
|
1326
|
+
* Public images keep serving. They are not counted here, a paused site
|
|
1327
|
+
* serves no page that asks for one, and the ones the edge holds would
|
|
1328
|
+
* keep serving from it whatever this said. A private image counts, so it
|
|
1329
|
+
* stops with the band like a file; the console's own preview does not.
|
|
1330
|
+
*
|
|
1331
|
+
* Before the provider redirect, which would otherwise hand a paused org's
|
|
1332
|
+
* film to the provider, and before the 304, which would renew a browser's
|
|
1333
|
+
* copy for another minute. `503` with `Retry-After` and `no-store`, the
|
|
1334
|
+
* shape the paused site's pages answer with: the pause lifts on an
|
|
1335
|
+
* upgrade or at the month's end, and nothing may keep the refusal past
|
|
1336
|
+
* either.
|
|
1337
|
+
*/ if (countsTowardBand(docServedType) && (await mediaCdnScopeVerdict(scope)).bandwidthPaused) {
|
|
1338
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1339
|
+
res.setHeader('Retry-After', String(BANDWIDTH_CAP_RETRY_AFTER_SECONDS));
|
|
1340
|
+
res.status(503).json({
|
|
1341
|
+
error: 'Over the monthly traffic limit'
|
|
1342
|
+
});
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1075
1345
|
/*
|
|
1076
1346
|
* VIDEO FROM THE DELIVERY PROVIDER (AGL-2824).
|
|
1077
1347
|
*
|
|
@@ -1093,7 +1363,7 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1093
1363
|
* private asset's URL expires no later than the signature that reached
|
|
1094
1364
|
* it. It is not counted against the caller's delivery budget, which
|
|
1095
1365
|
* guards the bytes this route sends; a redirect sends none.
|
|
1096
|
-
*/ const deliveryProvider = usePoster || useVariant || download ? null : mediaDeliveryProvider('deliver');
|
|
1366
|
+
*/ const deliveryProvider = usePoster || useVariant || useDisplay || download ? null : mediaDeliveryProvider('deliver');
|
|
1097
1367
|
if (deliveryProvider) {
|
|
1098
1368
|
var _scope_contextHostId;
|
|
1099
1369
|
const delivery = await mediaDeliveryRedirect(_extends({
|
|
@@ -1124,26 +1394,72 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1124
1394
|
res.setHeader('Cache-Control', 'private, no-store');
|
|
1125
1395
|
res.setHeader('Location', delivery.location);
|
|
1126
1396
|
// A play starts here, so it is counted here. `redirects` says how many
|
|
1127
|
-
// of the serves left through the provider; the bytes
|
|
1128
|
-
// provider's to measure, because none leave from this route.
|
|
1397
|
+
// of the serves left through the provider; the per-asset `bytes` stay
|
|
1398
|
+
// the provider's to measure, because none leave from this route.
|
|
1399
|
+
//
|
|
1400
|
+
// The bandwidth band is not the provider's (AGL-3474): moving a film
|
|
1401
|
+
// off origin must not move it off the meter. A player sends every
|
|
1402
|
+
// range of a sitting to the URL it was redirected to, so this request
|
|
1403
|
+
// is the only one of the sitting that reaches us, and it is counted
|
|
1404
|
+
// at the copy's full size — the most one sitting sends, whatever range
|
|
1405
|
+
// a player opens with.
|
|
1406
|
+
let evaluation = null;
|
|
1407
|
+
let recorded = null;
|
|
1129
1408
|
if (req.method === 'GET') {
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
merge: true
|
|
1141
|
-
}).catch(()=>undefined);
|
|
1409
|
+
const bandwidthBytes = teamPreview ? 0 : delivery.sizeBytes;
|
|
1410
|
+
recorded = recordMediaServe({
|
|
1411
|
+
firestore,
|
|
1412
|
+
collection: isOrg ? 'orgs' : 'hosts',
|
|
1413
|
+
scopeId,
|
|
1414
|
+
mediaId,
|
|
1415
|
+
bandwidthBytes,
|
|
1416
|
+
redirect: true
|
|
1417
|
+
});
|
|
1418
|
+
evaluation = mediaCdnBandwidthEvaluation(scope, bandwidthBytes);
|
|
1142
1419
|
}
|
|
1143
1420
|
res.status(302).end();
|
|
1421
|
+
// Awaited after the response, as the evaluation is, so the function
|
|
1422
|
+
// lives until the count is written rather than being frozen with it
|
|
1423
|
+
// in flight.
|
|
1424
|
+
await Promise.all([
|
|
1425
|
+
recorded,
|
|
1426
|
+
evaluation
|
|
1427
|
+
]);
|
|
1144
1428
|
return;
|
|
1145
1429
|
}
|
|
1146
1430
|
}
|
|
1431
|
+
/*
|
|
1432
|
+
* LAZY REGENERATION (AGL-3486) — see `media-delivery-regeneration.ts`.
|
|
1433
|
+
*
|
|
1434
|
+
* An asset whose delivery copies an older encoder made is answered from
|
|
1435
|
+
* what exists, and regenerated once the response has gone. Scheduled
|
|
1436
|
+
* before the 304 exit, because a browser revalidating its copy is a view
|
|
1437
|
+
* of the asset as much as a download is.
|
|
1438
|
+
*/ if (req.method === 'GET' && mediaDeliveryCopiesStale({
|
|
1439
|
+
contentType: snapshot.get('contentType'),
|
|
1440
|
+
cdnPath: snapshot.get('cdnPath'),
|
|
1441
|
+
sizeBytes: snapshot.get('sizeBytes'),
|
|
1442
|
+
width: snapshot.get('width'),
|
|
1443
|
+
[MEDIA_VARIANT_ENCODER_VERSION_FIELD]: snapshot.get(MEDIA_VARIANT_ENCODER_VERSION_FIELD),
|
|
1444
|
+
variantRegeneration: snapshot.get('variantRegeneration')
|
|
1445
|
+
})) {
|
|
1446
|
+
scheduleAfterResponse(async ()=>{
|
|
1447
|
+
const regenerationPath = mediaStoragePathInScope({
|
|
1448
|
+
storagePath: snapshot.get('storagePath'),
|
|
1449
|
+
base: `${isOrg ? 'orgs' : 'hosts'}/${scopeId}`,
|
|
1450
|
+
mediaId,
|
|
1451
|
+
onRefused: ()=>undefined
|
|
1452
|
+
});
|
|
1453
|
+
const outcome = await regenerateMediaDeliveryCopies({
|
|
1454
|
+
docRef: snapshot.ref,
|
|
1455
|
+
bucket: firebaseAdmin.app().storage().bucket(process.env['NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET']),
|
|
1456
|
+
basePath: regenerationPath
|
|
1457
|
+
});
|
|
1458
|
+
if (outcome === 'failed') {
|
|
1459
|
+
console.error('[media-cdn] delivery copies could not be regenerated', scopeSegment, mediaId);
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1147
1463
|
if (!hashed) {
|
|
1148
1464
|
setCacheControl(stableCacheControlFor(docServedType));
|
|
1149
1465
|
if (etag) res.setHeader('ETag', etag);
|
|
@@ -1181,8 +1497,8 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1181
1497
|
// and `parseMediaRendition` refuses a key or extension that is not
|
|
1182
1498
|
// `[a-z0-9-]`. So no representation can address an object the master
|
|
1183
1499
|
// could not.
|
|
1184
|
-
const objectPath = usePoster ? usePosterVariant ? `${basePath}${MEDIA_POSTER_OBJECT_SUFFIX}__w${width}.webp` : mediaPosterObjectPath(basePath) : rendition ? mediaRenditionObjectPath(basePath, rendition) : useVariant ? `${basePath}__w${width}.webp` : basePath;
|
|
1185
|
-
|
|
1500
|
+
const objectPath = usePoster ? usePosterVariant ? `${basePath}${MEDIA_POSTER_OBJECT_SUFFIX}__w${width}.webp` : mediaPosterObjectPath(basePath) : rendition ? mediaRenditionObjectPath(basePath, rendition) : useVariant ? `${basePath}__w${width}.webp` : useDisplay ? mediaDisplayObjectPath(basePath) : basePath;
|
|
1501
|
+
let file = bucket.file(objectPath);
|
|
1186
1502
|
// The caller's count (AGL-2812), asked here and nowhere earlier: past every
|
|
1187
1503
|
// gate and the 304 exit, where the Storage reads begin. It starts before the
|
|
1188
1504
|
// metadata read and is awaited after it, so the two run together. It never
|
|
@@ -1192,9 +1508,20 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1192
1508
|
remoteAddress: (_req_socket = req.socket) == null ? void 0 : _req_socket.remoteAddress,
|
|
1193
1509
|
rateClass: mediaCdnEdgeCacheable(docServedType) ? 'image' : 'non-image'
|
|
1194
1510
|
}) : null;
|
|
1195
|
-
|
|
1511
|
+
let [metadata] = await file.getMetadata().catch(()=>[
|
|
1196
1512
|
null
|
|
1197
1513
|
]);
|
|
1514
|
+
// A display copy the document names but Storage does not hold degrades
|
|
1515
|
+
// to the original — what this URL served before display copies existed —
|
|
1516
|
+
// rather than to a 404 on a live page.
|
|
1517
|
+
let servesDisplay = useDisplay;
|
|
1518
|
+
if (!metadata && useDisplay) {
|
|
1519
|
+
servesDisplay = false;
|
|
1520
|
+
file = bucket.file(basePath);
|
|
1521
|
+
[metadata] = await file.getMetadata().catch(()=>[
|
|
1522
|
+
null
|
|
1523
|
+
]);
|
|
1524
|
+
}
|
|
1198
1525
|
if (!metadata) {
|
|
1199
1526
|
res.status(404).json({
|
|
1200
1527
|
error: 'Not found'
|
|
@@ -1214,7 +1541,7 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1214
1541
|
});
|
|
1215
1542
|
return;
|
|
1216
1543
|
}
|
|
1217
|
-
const servedType = usePoster || useVariant ? 'image/webp' : rendition ? rendition.contentType : String((_ref3 = (_metadata_contentType = metadata.contentType) != null ? _metadata_contentType : snapshot.get('contentType')) != null ? _ref3 : 'application/octet-stream');
|
|
1544
|
+
const servedType = usePoster || useVariant ? 'image/webp' : rendition ? rendition.contentType : servesDisplay && display ? display.contentType : String((_ref3 = (_metadata_contentType = metadata.contentType) != null ? _metadata_contentType : snapshot.get('contentType')) != null ? _ref3 : 'application/octet-stream');
|
|
1218
1545
|
res.setHeader('Content-Type', servedType);
|
|
1219
1546
|
// AGL-1474: an SVG (or anything else a browser treats as a document) gets
|
|
1220
1547
|
// the sandboxing policy. Everything else keeps the base one set above —
|
|
@@ -1308,20 +1635,29 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1308
1635
|
return;
|
|
1309
1636
|
}
|
|
1310
1637
|
// Delivery volume (AGL-176): per-asset serves/bytes on the AGL-82
|
|
1311
|
-
// analytics day-doc,
|
|
1638
|
+
// analytics day-doc, awaited after the body rather than ahead of it.
|
|
1639
|
+
// Only cache MISSES reach this
|
|
1312
1640
|
// code — edge-cached responses aren't counted, so these are origin
|
|
1313
|
-
// serves, not user-facing totals
|
|
1641
|
+
// serves, not user-facing totals.
|
|
1314
1642
|
// Hot-doc note: a single day-doc caps at ~1 write/sec sustained;
|
|
1315
1643
|
// acceptable at current traffic, shard or sample if an asset gets hot.
|
|
1316
1644
|
//
|
|
1317
1645
|
// Written before the first byte, so `bytes` is what this request asked
|
|
1318
1646
|
// the origin to send. A client that abandons the stream receives less;
|
|
1319
1647
|
// the figure is the ceiling on what left, not a count of what arrived.
|
|
1648
|
+
//
|
|
1649
|
+
// The same bytes count against the org's bandwidth band when
|
|
1650
|
+
// `countsTowardBand` says so (above). In this write, so counting costs no
|
|
1651
|
+
// write of its own.
|
|
1652
|
+
const countedBytes = countsTowardBand(servedType) ? servedBytes : 0;
|
|
1320
1653
|
const day = new Date().toISOString().slice(0, 10);
|
|
1321
|
-
|
|
1654
|
+
const recorded = firestore.collection(isOrg ? 'orgs' : 'hosts').doc(scopeId).collection('analytics').doc(day).set(_extends({
|
|
1322
1655
|
// Retention (AGL-1844): every writer of a day doc stamps the
|
|
1323
1656
|
// day-anchored expiry the TTL policy sweeps on.
|
|
1324
|
-
expiresAt: analyticsDayExpiresAt(day)
|
|
1657
|
+
expiresAt: analyticsDayExpiresAt(day)
|
|
1658
|
+
}, countedBytes > 0 ? {
|
|
1659
|
+
[MEDIA_BANDWIDTH_DAY_FIELD]: firebaseAdmin.firestore.FieldValue.increment(countedBytes)
|
|
1660
|
+
} : {}, {
|
|
1325
1661
|
media: {
|
|
1326
1662
|
[mediaId]: {
|
|
1327
1663
|
serves: firebaseAdmin.firestore.FieldValue.increment(1),
|
|
@@ -1332,9 +1668,13 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1332
1668
|
bytes: firebaseAdmin.firestore.FieldValue.increment(servedBytes)
|
|
1333
1669
|
}
|
|
1334
1670
|
}
|
|
1335
|
-
}, {
|
|
1671
|
+
}), {
|
|
1336
1672
|
merge: true
|
|
1337
|
-
}).catch(()=>
|
|
1673
|
+
}).catch((error)=>logUncountedServe(scopeSegment, mediaId, error));
|
|
1674
|
+
// Totalled beside the stream rather than ahead of it, and awaited — with
|
|
1675
|
+
// the count above — once the stream ends, so the function lives until
|
|
1676
|
+
// both are done.
|
|
1677
|
+
const evaluation = countedBytes > 0 ? mediaCdnBandwidthEvaluation(scope, countedBytes) : null;
|
|
1338
1678
|
if (partial) res.status(206);
|
|
1339
1679
|
// `start`/`end` are inclusive in `createReadStream`, matching the parsed
|
|
1340
1680
|
// range — GCS is asked for exactly the requested bytes and nothing is
|
|
@@ -1346,10 +1686,17 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1346
1686
|
// rather than `pipe` because streaming needs teardown in both directions,
|
|
1347
1687
|
// which `pipe` does not give — a failed Storage read must fail the
|
|
1348
1688
|
// response, and a client that stops reading must stop the Storage read.
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1689
|
+
try {
|
|
1690
|
+
await pipeline(file.createReadStream(partial ? {
|
|
1691
|
+
start: range.start,
|
|
1692
|
+
end: range.end
|
|
1693
|
+
} : {}), res);
|
|
1694
|
+
} finally{
|
|
1695
|
+
await Promise.all([
|
|
1696
|
+
recorded,
|
|
1697
|
+
evaluation
|
|
1698
|
+
]);
|
|
1699
|
+
}
|
|
1353
1700
|
} catch (error) {
|
|
1354
1701
|
if (mediaCdnClientWentAway(error, res)) return;
|
|
1355
1702
|
console.error('serveMediaCdn failed', scopeSegment, mediaId, error);
|
|
@@ -1366,5 +1713,12 @@ export function parseMediaCdnRange(header, size) {
|
|
|
1366
1713
|
}
|
|
1367
1714
|
}
|
|
1368
1715
|
}
|
|
1716
|
+
/**
|
|
1717
|
+
* A serve the day doc did not take. The response has already gone, so the
|
|
1718
|
+
* failure is the log's to report rather than the visitor's — but reported,
|
|
1719
|
+
* because a dropped count is bandwidth the band and the invoice never see.
|
|
1720
|
+
*/ function logUncountedServe(scopeSegment, mediaId, error) {
|
|
1721
|
+
console.error('[media-cdn] serve not counted', scopeSegment, mediaId, error);
|
|
1722
|
+
}
|
|
1369
1723
|
|
|
1370
1724
|
//# sourceMappingURL=serve-media-cdn.js.map
|