diff --git a/backend/__tests__/services/usageV3.test.js b/backend/__tests__/services/usageV3.test.js index 0f53da6e..16e5f251 100644 --- a/backend/__tests__/services/usageV3.test.js +++ b/backend/__tests__/services/usageV3.test.js @@ -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('events', t => { 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('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); }); + 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 () => { await db('feature_flags').insert({ key: 'faces', value: true }); await client.markUsed(['face_recognition']); diff --git a/backend/src/usage/UsageService.js b/backend/src/usage/UsageService.js index bd38341c..50595359 100644 --- a/backend/src/usage/UsageService.js +++ b/backend/src/usage/UsageService.js @@ -564,14 +564,31 @@ class UsageService { }); return null; } - const envelope = signPacket( - packet, - { - public_key: state.public_key, - private_key: this.decrypt(state.private_key_encrypted) - }, - new Date(this.now()) - ); + 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())); + } // 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 diff --git a/backend/src/usage/expandedSnapshot.js b/backend/src/usage/expandedSnapshot.js index 2b966367..b00019c6 100644 --- a/backend/src/usage/expandedSnapshot.js +++ b/backend/src/usage/expandedSnapshot.js @@ -82,9 +82,20 @@ async function expandSnapshot(db, { features, flags, used, now, version = 'usage }); result.webhooks.configured = await enabled('webhooks', 'active'); 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' })) 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_reveal.configured = await exists('events', ['allow_user_uploads', 'reveal_mode'], (query) => query.where({ allow_user_uploads: formatBoolean(true), reveal_mode: formatBoolean(true) })); diff --git a/backend/src/usage/features.v3.json b/backend/src/usage/features.v3.json index acb1de0b..3ac1646e 100644 --- a/backend/src/usage/features.v3.json +++ b/backend/src/usage/features.v3.json @@ -1182,18 +1182,18 @@ }, "used": null }, - "gallery_downloads": { + "gallery_downloads_restricted": { "category": "gallery_configuration", - "since": "usage.v2", + "since": "usage.v3", "measurement": "configuration", "configuration": "configuration", "name": { - "en": "Gallery downloads allowed", - "de": "Galerie-Downloads erlaubt" + "en": "Gallery downloads restricted", + "de": "Galerie-Downloads eingeschränkt" }, "configured": { - "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." + "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." }, "used": null }, diff --git a/docs/FEATURE_COVERAGE.md b/docs/FEATURE_COVERAGE.md index c5482816..977578ec 100644 --- a/docs/FEATURE_COVERAGE.md +++ b/docs/FEATURE_COVERAGE.md @@ -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_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_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. | | `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. | @@ -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. | | `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/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/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. | diff --git a/docs/usage-coverage.v3.json b/docs/usage-coverage.v3.json index b57150ba..d95d6474 100644 --- a/docs/usage-coverage.v3.json +++ b/docs/usage-coverage.v3.json @@ -22,7 +22,7 @@ "signals": [ "galleries", "gallery_guest_uploads", - "gallery_downloads", + "gallery_downloads_restricted", "gallery_client_access", "gallery_watermarks", "gallery_image_protection", @@ -569,7 +569,7 @@ "signals": [ "galleries", "gallery_guest_uploads", - "gallery_downloads", + "gallery_downloads_restricted", "gallery_client_access", "gallery_watermarks", "gallery_reveal", @@ -1774,7 +1774,7 @@ "gallery_feedback_color_labels", "gallery_guest_accounts", "gallery_guest_uploads", - "gallery_downloads", + "gallery_downloads_restricted", "download_resolution_picker", "gallery_client_access", "gallery_watermarks", diff --git a/frontend/src/features/settings/usageFeatures.v3.json b/frontend/src/features/settings/usageFeatures.v3.json index acb1de0b..3ac1646e 100644 --- a/frontend/src/features/settings/usageFeatures.v3.json +++ b/frontend/src/features/settings/usageFeatures.v3.json @@ -1182,18 +1182,18 @@ }, "used": null }, - "gallery_downloads": { + "gallery_downloads_restricted": { "category": "gallery_configuration", - "since": "usage.v2", + "since": "usage.v3", "measurement": "configuration", "configuration": "configuration", "name": { - "en": "Gallery downloads allowed", - "de": "Galerie-Downloads erlaubt" + "en": "Gallery downloads restricted", + "de": "Galerie-Downloads eingeschränkt" }, "configured": { - "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." + "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." }, "used": null }, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 0f3567c4..faa31286 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -335,9 +335,9 @@ "name": "Gast-Uploads aktiviert", "configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." }, - "gallery_downloads": { - "name": "Galerie-Downloads erlaubt", - "configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + "gallery_downloads_restricted": { + "name": "Galerie-Downloads eingeschränkt", + "configured": "Mindestens eine Galerie hat Downloads abgeschaltet; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." }, "download_resolution_picker": { "name": "Download-Auflösungswahl aktiviert", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4b300b5c..f20b1681 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -335,9 +335,9 @@ "name": "Guest uploads enabled", "configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts." }, - "gallery_downloads": { - "name": "Gallery downloads allowed", - "configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts." + "gallery_downloads_restricted": { + "name": "Gallery downloads restricted", + "configured": "At least one gallery has downloads switched off; only existence across the installation, never gallery IDs or counts." }, "download_resolution_picker": { "name": "Download resolution picker enabled",