fix(usage): report restricted gallery downloads in v3 instead of an always-true signal

gallery_downloads.configured was true on every installation with a
gallery. allow_downloads ships true — column default in migration 037
and the create route both set it — and the snapshot asked "at least one
gallery has it on". The fleet value was ~100% by construction and could
not separate a deliberate configuration from an untouched one.

v2 consented to that key under that description, so v2 keeps sending it
unchanged. v3 replaces it with gallery_downloads_restricted: at least
one gallery has downloads switched off, which is the only state of that
column anyone actually decides. Same catalog position, so the disclosed
capability count stays at 86; the frontend copy, the EN/DE catalog
strings, the coverage inventory and FEATURE_COVERAGE.md follow.

Done in v3 rather than a v4 because v3 is on main and in no release
yet, so nobody has consented to it. The collector carries the same
catalog and has to take this change before the release that ships v3.

One guard for the window in which :main / :beta images already carried
the old v3 catalog. A report queued under it fails local validation on
this build, and deliver() left a locally invalid report pending for
good, blocking every operation behind it. A report's payload is derived
state, so deliver() now rebuilds it from the current snapshot in place
and sends that. Packet ID and sequence are kept — a re-signed retry has
to reuse them so a lost acknowledgement does not duplicate data — and
reports only: a stale registration, deletion or command is a genuine
conflict and keeps the existing handling.

Tests: the v3 snapshot counts a switched-off gallery and ignores
enabled ones, v2 still reports the old key with the old meaning, and a
stale queued report goes out rebuilt under the same packet id while a
valid one is sent untouched.

Relates to issue 1308
This commit is contained in:
Paul Nothaft
2026-09-06 20:42:28 +02:00
parent 25c3e3d7b8
commit 02b353e54f
9 changed files with 124 additions and 33 deletions
+64 -1
View File
@@ -27,7 +27,7 @@ for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] :
await db.schema.createTable('feature_flags', t => { t.string('key').primary(); t.boolean('value'); }); await db.schema.createTable('feature_flags', t => { t.string('key').primary(); t.boolean('value'); });
await db.schema.createTable('events', t => { await db.schema.createTable('events', t => {
t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id'); t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id');
t.string('default_photo_sort'); t.boolean('is_archived'); t.boolean('is_draft'); t.string('default_photo_sort'); t.boolean('is_archived'); t.boolean('is_draft'); t.boolean('allow_downloads');
}); });
await db.schema.createTable('photos', t => { t.increments('id'); t.integer('event_id'); t.string('media_type'); t.string('filename'); }); await db.schema.createTable('photos', t => { t.increments('id'); t.integer('event_id'); t.string('media_type'); t.string('filename'); });
await db.schema.createTable('css_templates', t => { t.increments('id'); t.boolean('is_enabled'); t.text('css_content'); }); await db.schema.createTable('css_templates', t => { t.increments('id'); t.boolean('is_enabled'); t.text('css_content'); });
@@ -126,6 +126,69 @@ for (const engine of ['sqlite3', ...(process.env.PICPEAK_PG_TEST_URL ? ['pg'] :
expect((await snap({})).gallery_folders.configured).toBe(false); expect((await snap({})).gallery_folders.configured).toBe(false);
}); });
test('gallery_downloads_restricted counts galleries with downloads switched off, and v2 keeps its old key', async () => {
// allow_downloads ships true, so the v2 key was true on every install
// with a gallery. Only switching downloads off is a decision.
const snap = version => expandSnapshot(db, { features: p.emptyFeatures('usage.v1'), flags: {}, used: new Set(), now, version });
expect((await snap('usage.v3')).gallery_downloads_restricted).toEqual({ configured: false });
expect(await snap('usage.v3')).not.toHaveProperty('gallery_downloads');
await db('events').insert([{ allow_downloads: true }, { allow_downloads: true }]);
expect((await snap('usage.v3')).gallery_downloads_restricted.configured).toBe(false);
expect((await snap('usage.v2')).gallery_downloads).toEqual({ configured: true });
expect(await snap('usage.v2')).not.toHaveProperty('gallery_downloads_restricted');
await db('events').insert({ allow_downloads: false });
expect((await snap('usage.v3')).gallery_downloads_restricted.configured).toBe(true);
expect((await snap('usage.v2')).gallery_downloads.configured).toBe(true);
});
test('a report queued under the replaced catalog is rebuilt in place, keeping its packet id', async () => {
const identity = p.generateIdentity();
const posted = [];
const service = new UsageService(db, {
now: () => now, secret: 'v3-test-only-secret'.repeat(3), endpoint: 'http://127.0.0.1:9/',
fetch: async (_url, init) => { posted.push(JSON.parse(init.body).packet); throw new Error('collector unreachable'); },
});
service.binding = async () => 'b'.repeat(64);
const report = (features) => ({
picpeak_version: '1.0.0', report_date: '2026-09-05', generated_at: new Date(now).toISOString(),
features, gallery_layouts: [], inventory: { galleries: 0, photos: 0 },
});
// The v3 catalog as it stood before gallery_downloads_restricted replaced gallery_downloads.
const { gallery_downloads_restricted, ...rest } = p.emptyFeatures('usage.v3');
const stale = { ...rest, gallery_downloads: gallery_downloads_restricted };
const seed = (payload) => db('product_usage_state').where({ id: 1 }).update({
status: 'active', installation_id: identity.installation_id, public_key: identity.public_key,
private_key_encrypted: service.encrypt(identity.private_key), instance_binding: 'b'.repeat(64),
sequence: 1, last_error: null, attempts: 3, next_attempt_at: now + 60_000,
pending_packet: JSON.stringify(p.makePacket(identity, 'report', 2, payload, 'usage.v3')),
});
await seed(report(stale));
const queued = JSON.parse((await db('product_usage_state').where({ id: 1 }).first()).pending_packet);
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
// Sent once, under the current catalog, as the same packet.
expect(posted).toHaveLength(1);
expect(posted[0].packet_id).toBe(queued.packet_id);
expect(posted[0].sequence).toBe(2);
expect(posted[0].payload.features).toHaveProperty('gallery_downloads_restricted');
expect(posted[0].payload.features).not.toHaveProperty('gallery_downloads');
// The rebuilt packet is what stays queued for the ordinary retry path.
let row = await db('product_usage_state').where({ id: 1 }).first();
const retained = JSON.parse(row.pending_packet);
expect(retained.packet_id).toBe(queued.packet_id);
expect(retained.payload.features).toHaveProperty('gallery_downloads_restricted');
expect(row.status).toBe('active');
expect(row.last_error).toBe('DELIVERY_FAILED');
// Narrow: a report that still validates is sent as queued, payload untouched.
await seed(report(p.emptyFeatures('usage.v3')));
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
expect(posted).toHaveLength(2);
expect(posted[1].payload.report_date).toBe('2026-09-05');
row = await db('product_usage_state').where({ id: 1 }).first();
expect(JSON.parse(row.pending_packet).payload.report_date).toBe('2026-09-05');
});
test('ML recognition is already represented without querying faces or results', async () => { test('ML recognition is already represented without querying faces or results', async () => {
await db('feature_flags').insert({ key: 'faces', value: true }); await db('feature_flags').insert({ key: 'faces', value: true });
await client.markUsed(['face_recognition']); await client.markUsed(['face_recognition']);
+25 -8
View File
@@ -564,14 +564,31 @@ class UsageService {
}); });
return null; return null;
} }
const envelope = signPacket( const identity = {
packet, public_key: state.public_key,
{ private_key: this.decrypt(state.private_key_encrypted)
public_key: state.public_key, };
private_key: this.decrypt(state.private_key_encrypted) let envelope;
}, try {
new Date(this.now()) 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()));
}
// Last check before anything leaves. The guard at the top of this // Last check before anything leaves. The guard at the top of this
// method runs before the binding lookup above, which is asynchronous — // method runs before the binding lookup above, which is asynchronous —
// so a withdrawal that COMPLETED during it would previously still have // so a withdrawal that COMPLETED during it would previously still have
+12 -1
View File
@@ -82,9 +82,20 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage
}); });
result.webhooks.configured = await enabled('webhooks', 'active'); result.webhooks.configured = await enabled('webhooks', 'active');
for (const [key, column] of Object.entries({ for (const [key, column] of Object.entries({
gallery_guest_uploads: 'allow_user_uploads', gallery_downloads: 'allow_downloads', gallery_guest_uploads: 'allow_user_uploads',
gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads' gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads'
})) result[key].configured = await enabled('events', column); })) 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') {
result.gallery_downloads_restricted.configured = await exists('events', ['allow_downloads'], (query) =>
query.where('allow_downloads', formatBoolean(false)));
} else {
result.gallery_downloads.configured = await enabled('events', 'allow_downloads');
}
result.gallery_watermarks.configured ||= truth(settings.branding_watermark_enabled); result.gallery_watermarks.configured ||= truth(settings.branding_watermark_enabled);
result.gallery_reveal.configured = await exists('events', ['allow_user_uploads', 'reveal_mode'], (query) => result.gallery_reveal.configured = await exists('events', ['allow_user_uploads', 'reveal_mode'], (query) =>
query.where({ allow_user_uploads: formatBoolean(true), reveal_mode: formatBoolean(true) })); query.where({ allow_user_uploads: formatBoolean(true), reveal_mode: formatBoolean(true) }));
+6 -6
View File
@@ -1182,18 +1182,18 @@
}, },
"used": null "used": null
}, },
"gallery_downloads": { "gallery_downloads_restricted": {
"category": "gallery_configuration", "category": "gallery_configuration",
"since": "usage.v2", "since": "usage.v3",
"measurement": "configuration", "measurement": "configuration",
"configuration": "configuration", "configuration": "configuration",
"name": { "name": {
"en": "Gallery downloads allowed", "en": "Gallery downloads restricted",
"de": "Galerie-Downloads erlaubt" "de": "Galerie-Downloads eingeschränkt"
}, },
"configured": { "configured": {
"en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", "en": "At least one gallery has downloads switched off; only existence across the installation, never gallery IDs or counts.",
"de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." "de": "Mindestens eine Galerie hat Downloads abgeschaltet; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
}, },
"used": null "used": null
}, },
+2 -2
View File
@@ -129,7 +129,7 @@ Definitions are shipped byte-identically in both applications as
| `gallery_feedback_color_labels` — Gallery color labels enabled / Galerie-Farblabels aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. | | `gallery_feedback_color_labels` — Gallery color labels enabled / Galerie-Farblabels aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
| `gallery_guest_accounts` — Guest identities enabled / Gastidentitäten aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. | | `gallery_guest_accounts` — Guest identities enabled / Gastidentitäten aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
| `gallery_guest_uploads` — Guest uploads enabled / Gast-Uploads aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. | | `gallery_guest_uploads` — Guest uploads enabled / Gast-Uploads aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
| `gallery_downloads` — Gallery downloads allowed / Galerie-Downloads erlaubt | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. | | `gallery_downloads_restricted` — Gallery downloads restricted / Galerie-Downloads eingeschränkt | usage.v3 | At least one gallery has downloads switched off; only existence across the installation, never gallery IDs or counts. Replaces v2's `gallery_downloads`, which was true on every installation with a gallery because downloads ship enabled. | Not collected: configuration only. |
| `download_resolution_picker` — Download resolution picker enabled / Download-Auflösungswahl aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. | | `download_resolution_picker` — Download resolution picker enabled / Download-Auflösungswahl aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
| `gallery_client_access` — Client access enabled / Client-Zugang aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. | | `gallery_client_access` — Client access enabled / Client-Zugang aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
| `gallery_watermarks` — Watermarks enabled / Wasserzeichen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. | | `gallery_watermarks` — Watermarks enabled / Wasserzeichen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | Not collected: configuration only. |
@@ -179,7 +179,7 @@ Definitions are shipped byte-identically in both applications as
| `adminEmail.js` | partial: `messaging`, `incoming_mail`, `smtp`, `email_templates`, `email_webhook`, `reminder_emails` | Admin message operation/template edit, actual successful manual send/test transport and non-skipped manual IMAP poll/test. Reminder flag configuration only. No automated sends/polls, received-message or recipient data, queue/log reads, mailbox addresses or templates. | | `adminEmail.js` | partial: `messaging`, `incoming_mail`, `smtp`, `email_templates`, `email_webhook`, `reminder_emails` | Admin message operation/template edit, actual successful manual send/test transport and non-skipped manual IMAP poll/test. Reminder flag configuration only. No automated sends/polls, received-message or recipient data, queue/log reads, mailbox addresses or templates. |
| `adminEventRename.js` | partial: `galleries` | Successful rename only, not validate-rename. No former/new names or identifiers. | | `adminEventRename.js` | partial: `galleries` | Successful rename only, not validate-rename. No former/new names or identifiers. |
| `adminEvents/archiveBulk.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. | | `adminEvents/archiveBulk.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. |
| `adminEvents/crud.js` | partial: `galleries`, `gallery_guest_uploads`, `gallery_downloads`, `gallery_client_access`, `gallery_watermarks`, `gallery_reveal`, `gallery_expiration`, `gallery_sharing`, `custom_css`, `gallery_capture_date_sort` | Admin creation/edit/publish etc. set galleries; sharing has its own fixed key. Guest/download/protection/reveal/expiry are configuration only; themes contribute controlled layouts and CSS presence. No gallery metadata or guest action history. v3 adds only: gallery_capture_date_sort. Exact definitions are in features.v3.json; configuration-only signals never observe the public surface. | | `adminEvents/crud.js` | partial: `galleries`, `gallery_guest_uploads`, `gallery_downloads_restricted`, `gallery_client_access`, `gallery_watermarks`, `gallery_reveal`, `gallery_expiration`, `gallery_sharing`, `custom_css`, `gallery_capture_date_sort` | Admin creation/edit/publish etc. set galleries; sharing has its own fixed key. Guest/download/protection/reveal/expiry are configuration only; themes contribute controlled layouts and CSS presence. No gallery metadata or guest action history. v3 adds only: gallery_capture_date_sort, and replaces gallery_downloads with gallery_downloads_restricted (downloads ship enabled, so only switching them off is a decision). Exact definitions are in features.v3.json; configuration-only signals never observe the public surface. |
| `adminEvents/downloadResolutions.js` | configuration: `download_resolution_picker` | Only whether a picker is configured globally or in a gallery. No chosen resolution, download event or counts. | | `adminEvents/downloadResolutions.js` | configuration: `download_resolution_picker` | Only whether a picker is configured globally or in a gallery. No chosen resolution, download event or counts. |
| `adminEvents/faces.js` | partial: `face_recognition` | Effective flag plus successful admin faces/people operation. No health polling, embeddings, names, groups, detections or visitor searches. | | `adminEvents/faces.js` | partial: `face_recognition` | Effective flag plus successful admin faces/people operation. No health polling, embeddings, names, groups, detections or visitor searches. |
| `adminEvents/helpers.js` | composition: no telemetry | Router composition / helpers; decisions are recorded for each mounted family. | | `adminEvents/helpers.js` | composition: no telemetry | Router composition / helpers; decisions are recorded for each mounted family. |
+3 -3
View File
@@ -22,7 +22,7 @@
"signals": [ "signals": [
"galleries", "galleries",
"gallery_guest_uploads", "gallery_guest_uploads",
"gallery_downloads", "gallery_downloads_restricted",
"gallery_client_access", "gallery_client_access",
"gallery_watermarks", "gallery_watermarks",
"gallery_image_protection", "gallery_image_protection",
@@ -569,7 +569,7 @@
"signals": [ "signals": [
"galleries", "galleries",
"gallery_guest_uploads", "gallery_guest_uploads",
"gallery_downloads", "gallery_downloads_restricted",
"gallery_client_access", "gallery_client_access",
"gallery_watermarks", "gallery_watermarks",
"gallery_reveal", "gallery_reveal",
@@ -1774,7 +1774,7 @@
"gallery_feedback_color_labels", "gallery_feedback_color_labels",
"gallery_guest_accounts", "gallery_guest_accounts",
"gallery_guest_uploads", "gallery_guest_uploads",
"gallery_downloads", "gallery_downloads_restricted",
"download_resolution_picker", "download_resolution_picker",
"gallery_client_access", "gallery_client_access",
"gallery_watermarks", "gallery_watermarks",
@@ -1182,18 +1182,18 @@
}, },
"used": null "used": null
}, },
"gallery_downloads": { "gallery_downloads_restricted": {
"category": "gallery_configuration", "category": "gallery_configuration",
"since": "usage.v2", "since": "usage.v3",
"measurement": "configuration", "measurement": "configuration",
"configuration": "configuration", "configuration": "configuration",
"name": { "name": {
"en": "Gallery downloads allowed", "en": "Gallery downloads restricted",
"de": "Galerie-Downloads erlaubt" "de": "Galerie-Downloads eingeschränkt"
}, },
"configured": { "configured": {
"en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", "en": "At least one gallery has downloads switched off; only existence across the installation, never gallery IDs or counts.",
"de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." "de": "Mindestens eine Galerie hat Downloads abgeschaltet; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
}, },
"used": null "used": null
}, },
+3 -3
View File
@@ -335,9 +335,9 @@
"name": "Gast-Uploads aktiviert", "name": "Gast-Uploads aktiviert",
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." "configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
}, },
"gallery_downloads": { "gallery_downloads_restricted": {
"name": "Galerie-Downloads erlaubt", "name": "Galerie-Downloads eingeschränkt",
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." "configured": "Mindestens eine Galerie hat Downloads abgeschaltet; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
}, },
"download_resolution_picker": { "download_resolution_picker": {
"name": "Download-Auflösungswahl aktiviert", "name": "Download-Auflösungswahl aktiviert",
+3 -3
View File
@@ -335,9 +335,9 @@
"name": "Guest uploads enabled", "name": "Guest uploads enabled",
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts." "configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
}, },
"gallery_downloads": { "gallery_downloads_restricted": {
"name": "Gallery downloads allowed", "name": "Gallery downloads restricted",
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts." "configured": "At least one gallery has downloads switched off; only existence across the installation, never gallery IDs or counts."
}, },
"download_resolution_picker": { "download_resolution_picker": {
"name": "Download resolution picker enabled", "name": "Download resolution picker enabled",