fix(usage): introduce consented v4 without changing historical reports
This commit is contained in:
@@ -82,7 +82,7 @@ router.post(
|
||||
router.post(
|
||||
'/consent',
|
||||
wrap(async (req, res) => {
|
||||
if (!req.body || Object.keys(req.body).length !== 1 || !['usage.v2', 'usage.v3'].includes(schemaForConsent(req.body.consent_version)))
|
||||
if (!req.body || Object.keys(req.body).length !== 1 || !['usage.v2', 'usage.v3', 'usage.v4'].includes(schemaForConsent(req.body.consent_version)))
|
||||
throw new ValidationError('Explicit usage consent is required');
|
||||
res.json(await service.command('consent', { consent_version: req.body.consent_version }));
|
||||
})
|
||||
|
||||
@@ -539,6 +539,9 @@ class UsageService {
|
||||
return value;
|
||||
}
|
||||
async deliver(state) {
|
||||
// A lost receipt may mean this packet is already stored by the collector.
|
||||
// Preserve its original schema, date and payload across binary upgrades;
|
||||
// only re-sign transport metadata, never rebuild under the same packet ID.
|
||||
const packet = JSON.parse(state.pending_packet);
|
||||
if (
|
||||
packet.action !== 'delete' &&
|
||||
@@ -564,31 +567,14 @@ class UsageService {
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const identity = {
|
||||
public_key: state.public_key,
|
||||
private_key: this.decrypt(state.private_key_encrypted)
|
||||
};
|
||||
let envelope;
|
||||
try {
|
||||
envelope = signPacket(packet, identity, new Date(this.now()));
|
||||
} catch (error) {
|
||||
// A report queued under a catalog this build no longer ships — the
|
||||
// upgrade replaced a key in the same wire version — fails local
|
||||
// validation before anything is sent, and retrying cannot repair it.
|
||||
// Left as it was it blocked every operation behind it for good. A
|
||||
// report's payload is derived state, so rebuild it from the current
|
||||
// snapshot in place. The packet ID and sequence are kept: a re-signed
|
||||
// retry must reuse them so a lost acknowledgement does not duplicate
|
||||
// data. Reports only — a stale registration, deletion or command is a
|
||||
// genuine conflict and keeps the handling below.
|
||||
if (error.code !== 'INVALID_PACKET' || packet.action !== 'report') throw error;
|
||||
packet.payload = await this.snapshot(packet.schema_version);
|
||||
state.pending_packet = JSON.stringify(packet);
|
||||
await this.db('product_usage_state')
|
||||
.where({ id: 1, status: 'active' })
|
||||
.update({ pending_packet: state.pending_packet });
|
||||
envelope = signPacket(packet, identity, new Date(this.now()));
|
||||
}
|
||||
const envelope = signPacket(
|
||||
packet,
|
||||
{
|
||||
public_key: state.public_key,
|
||||
private_key: this.decrypt(state.private_key_encrypted)
|
||||
},
|
||||
new Date(this.now())
|
||||
);
|
||||
// Last check before anything leaves. The guard at the top of this
|
||||
// method runs before the binding lookup above, which is asynchronous —
|
||||
// so a withdrawal that COMPLETED during it would previously still have
|
||||
@@ -974,7 +960,7 @@ class UsageService {
|
||||
generated_at: now,
|
||||
features: expanded,
|
||||
gallery_layouts: [...layouts].sort(),
|
||||
...(version === 'usage.v3' ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {})
|
||||
...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -85,12 +85,7 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
|
||||
gallery_guest_uploads: 'allow_user_uploads',
|
||||
gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads'
|
||||
})) result[key].configured = await enabled('events', column);
|
||||
// allow_downloads ships true — column default and the create route both set
|
||||
// it — so "at least one gallery allows downloads" is true on every install
|
||||
// with a gallery and says nothing. v2 consented to that key under that
|
||||
// description, so v2 keeps sending it unchanged. v3 asks the question that
|
||||
// is actually a decision: has anyone switched downloads off.
|
||||
if (version === 'usage.v3') {
|
||||
if (version === 'usage.v4') {
|
||||
result.gallery_downloads_restricted.configured = await exists('events', ['allow_downloads'], (query) =>
|
||||
query.where('allow_downloads', formatBoolean(false)));
|
||||
} else {
|
||||
@@ -127,7 +122,7 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
|
||||
query.where({ feedback_enabled: formatBoolean(true), [column]: formatBoolean(true) }));
|
||||
result.gallery_guest_accounts.configured = await exists('event_feedback_settings', ['feedback_enabled', 'identity_mode'], (query) =>
|
||||
query.where('feedback_enabled', formatBoolean(true)).whereIn('identity_mode', ['guest', 'shared']));
|
||||
if (version === 'usage.v3') {
|
||||
if (['usage.v3', 'usage.v4'].includes(version)) {
|
||||
result.gallery_folders.configured = await exists('photo_categories', ['is_folder', 'event_id'], (query) =>
|
||||
query.where('is_folder', formatBoolean(true)).where((q) => q.whereNull('event_id').orWhereIn('event_id', db('events').select('id'))));
|
||||
result.transfer_upload_links.configured = Boolean(effective.transfers) && await exists('transfers',
|
||||
|
||||
@@ -1182,18 +1182,18 @@
|
||||
},
|
||||
"used": null
|
||||
},
|
||||
"gallery_downloads_restricted": {
|
||||
"gallery_downloads": {
|
||||
"category": "gallery_configuration",
|
||||
"since": "usage.v3",
|
||||
"since": "usage.v2",
|
||||
"measurement": "configuration",
|
||||
"configuration": "configuration",
|
||||
"name": {
|
||||
"en": "Gallery downloads restricted",
|
||||
"de": "Galerie-Downloads eingeschränkt"
|
||||
"en": "Gallery downloads allowed",
|
||||
"de": "Galerie-Downloads erlaubt"
|
||||
},
|
||||
"configured": {
|
||||
"en": "At least one gallery has downloads switched off; only existence across the installation, never gallery IDs or counts.",
|
||||
"de": "Mindestens eine Galerie hat Downloads abgeschaltet; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
"en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.",
|
||||
"de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"used": null
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,10 @@
|
||||
|
||||
// Vendored byte-identical in PicPeak. Existing wire versions stay immutable;
|
||||
// every expansion requires explicit consent to its own version.
|
||||
const CATALOG = require("./features.v3.json");
|
||||
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": CATALOG };
|
||||
const CONSENT_VERSIONS = { "usage.v1": "usage-consent.v1", "usage.v2": "usage-consent.v2", "usage.v3": "usage-consent.v3" };
|
||||
const CURRENT_SCHEMA_VERSION = "usage.v3";
|
||||
const CATALOG = require("./features.v4.json");
|
||||
const CATALOGS = { "usage.v2": require("./features.v2.json"), "usage.v3": require("./features.v3.json"), "usage.v4": CATALOG };
|
||||
const CONSENT_VERSIONS = { "usage.v1": "usage-consent.v1", "usage.v2": "usage-consent.v2", "usage.v3": "usage-consent.v3", "usage.v4": "usage-consent.v4" };
|
||||
const CURRENT_SCHEMA_VERSION = "usage.v4";
|
||||
const CURRENT_CONSENT_VERSION = CONSENT_VERSIONS[CURRENT_SCHEMA_VERSION];
|
||||
const schemaForConsent = (consent) => Object.keys(CONSENT_VERSIONS).find((version) => CONSENT_VERSIONS[version] === consent);
|
||||
const schemaRank = (version) => Object.keys(CONSENT_VERSIONS).indexOf(version);
|
||||
@@ -19,6 +19,10 @@ const LEGACY_FEATURE_KEYS = [
|
||||
"whatsapp", "backup", "s3_storage", "share_mounts",
|
||||
];
|
||||
const FEATURE_KEYS = Object.keys(CATALOG.features);
|
||||
// Historical questions remain independently visible after a newer schema
|
||||
// stops asking them. Never rename or invert a retained report's values.
|
||||
const ALL_FEATURES = Object.assign({}, ...Object.values(CATALOGS).map(c => c.features));
|
||||
const ALL_FEATURE_KEYS = Object.keys(ALL_FEATURES);
|
||||
const LAYOUTS = ["grid", "masonry", "carousel", "timeline", "mosaic", "gallery-premium", "gallery-story", "other"];
|
||||
const object = (properties, required = Object.keys(properties)) => ({
|
||||
type: "object", additionalProperties: false, properties, required,
|
||||
@@ -45,7 +49,7 @@ const report = (version) => object({
|
||||
key, object({ configured: boolean, ...(observesUse(key, version) ? { used: boolean } : {}) })
|
||||
]))),
|
||||
gallery_layouts: { type: "array", uniqueItems: true, maxItems: LAYOUTS.length, items: { enum: LAYOUTS } },
|
||||
...(version === "usage.v3" ? { inventory: object(Object.fromEntries(INVENTORY_KEYS.map(key => [key,
|
||||
...(["usage.v3", "usage.v4"].includes(version) ? { inventory: object(Object.fromEntries(INVENTORY_KEYS.map(key => [key,
|
||||
{ type: "integer", minimum: 0, maximum: MAX_INVENTORY_COUNT }
|
||||
]))) } : {}),
|
||||
});
|
||||
@@ -109,7 +113,7 @@ const ingressEnvelopeSchemas = Object.fromEntries(Object.entries(envelopeSchemas
|
||||
return [version, schema];
|
||||
}));
|
||||
module.exports = {
|
||||
FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CATALOGS, CONSENT_VERSIONS,
|
||||
FEATURE_KEYS, ALL_FEATURES, ALL_FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CATALOGS, CONSENT_VERSIONS,
|
||||
schemaForConsent, schemaRank, INVENTORY_KEYS, MAX_INVENTORY_COUNT, CURRENT_SCHEMA_VERSION,
|
||||
CURRENT_CONSENT_VERSION, featureKeysFor, observesUse, emptyFeatures,
|
||||
envelopeSchema, envelopeSchemas, ingressEnvelopeSchemas, payloads, payloadsByVersion,
|
||||
|
||||
Reference in New Issue
Block a user