fix(usage): introduce consented v4 without changing historical reports

This commit is contained in:
Paul Nothaft
2026-09-06 21:34:04 +02:00
parent 3cc893126d
commit ef8a52f02c
21 changed files with 5046 additions and 197 deletions
+1 -1
View File
@@ -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 }));
})
+12 -26
View File
@@ -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) } : {})
};
}
+2 -7
View File
@@ -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',
+6 -6
View File
@@ -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
+10 -6
View File
@@ -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,