From b53e5d97b481d4e489208c67b7eb12116b6666d7 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 5 Sep 2026 12:59:06 +0200 Subject: [PATCH] feat: add opt-in product usage and feedback integration (#1110) --- .env.example | 6 + backend/.env.example | 8 +- backend/__tests__/routes/adminUsage.test.js | 178 +++++ backend/migrations/core/201_product_usage.js | 34 + backend/package-lock.json | 100 ++- backend/package.json | 1 + backend/server.js | 2 + backend/src/middleware/productUsage.js | 50 ++ backend/src/routes/adminUsage.js | 118 ++++ backend/src/routes/auth.js | 4 + backend/src/services/productUsageService.js | 3 + backend/src/usage/UsageService.js | 639 ++++++++++++++++++ backend/src/usage/protocol.cjs | 155 +++++ backend/src/usage/schema.cjs | 138 ++++ docker-compose.yml | 3 + docs/PRODUCT_USAGE.md | 81 +++ frontend/src/components/admin/AdminLayout.tsx | 6 +- .../components/admin/ProductUsageNotice.tsx | 69 ++ .../__tests__/ProductUsageTab.test.tsx | 153 +++++ .../settings/tabs/ProductUsageTab.tsx | 428 ++++++++++++ frontend/src/i18n/locales/de.json | 63 ++ frontend/src/i18n/locales/en.json | 63 ++ frontend/src/pages/admin/SettingsPage.tsx | 8 +- frontend/src/services/productUsage.service.ts | 68 ++ 24 files changed, 2360 insertions(+), 18 deletions(-) create mode 100644 backend/__tests__/routes/adminUsage.test.js create mode 100644 backend/migrations/core/201_product_usage.js create mode 100644 backend/src/middleware/productUsage.js create mode 100644 backend/src/routes/adminUsage.js create mode 100644 backend/src/services/productUsageService.js create mode 100644 backend/src/usage/UsageService.js create mode 100644 backend/src/usage/protocol.cjs create mode 100644 backend/src/usage/schema.cjs create mode 100644 docs/PRODUCT_USAGE.md create mode 100644 frontend/src/components/admin/ProductUsageNotice.tsx create mode 100644 frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx create mode 100644 frontend/src/features/settings/tabs/ProductUsageTab.tsx create mode 100644 frontend/src/services/productUsage.service.ts diff --git a/.env.example b/.env.example index 9b0050ff..87fab8e9 100644 --- a/.env.example +++ b/.env.example @@ -336,3 +336,9 @@ LOGS=./logs # Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and # let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend # should you change VITE_API_URL at build time. + +# Optional product usage (#1110): disabled until explicit in-app consent. +# USAGE_COLLECTOR_URL=https://usage.picpeak.app +# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET. +# Keep this value stable until participation has been deleted. +# USAGE_ENCRYPTION_KEY= diff --git a/backend/.env.example b/backend/.env.example index 7f2fa965..3ed79921 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -119,4 +119,10 @@ ARCHIVE_PATH=/app/storage/events/archived # UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab # Logging -LOG_LEVEL=info \ No newline at end of file +LOG_LEVEL=info + +# Optional product usage (#1110): disabled until explicit in-app consent. +# USAGE_COLLECTOR_URL=https://usage.picpeak.app +# Encryption material for the backend-only signing key (32+ characters). +# Defaults to JWT_SECRET; keep it stable until participation has been deleted. +# USAGE_ENCRYPTION_KEY= diff --git a/backend/__tests__/routes/adminUsage.test.js b/backend/__tests__/routes/adminUsage.test.js new file mode 100644 index 00000000..75fba5f0 --- /dev/null +++ b/backend/__tests__/routes/adminUsage.test.js @@ -0,0 +1,178 @@ +const request = require('supertest'); +const express = require('express'); +const jwt = require('jsonwebtoken'); +const mockDb = require('knex')({ + client: 'sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true +}); +jest.mock('../../src/database/db', () => ({ + get db() { + return mockDb; + } +})); +jest.mock('../../src/utils/tokenRevocation', () => ({ + isTokenRevoked: jest.fn().mockResolvedValue(false) +})); +jest.mock('../../src/utils/sessionCutoff', () => ({ + isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) +})); +jest.mock('../../src/utils/logger', () => ({ + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + info: jest.fn() +})); +jest.mock('../../src/services/productUsageService', () => + Object.fromEntries( + [ + 'tick', + 'status', + 'dismiss', + 'enable', + 'disable', + 'preview', + 'export', + 'preferences', + 'command', + 'markUsed' + ].map((key) => [key, jest.fn().mockResolvedValue({ status: 'disabled' })]) + ) +); +const service = require('../../src/services/productUsageService'); +const { productUsage } = require('../../src/middleware/productUsage'); +const SECRET = 'usage-auth-test-secret-not-a-live-credential'; +const token = (type, id = 1) => + jwt.sign({ type, id }, SECRET, { + issuer: 'picpeak-auth', + algorithm: 'HS256' + }); +let app; +beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + await mockDb.schema.createTable('roles', (t) => { + t.increments('id'); + t.string('name'); + }); + await mockDb.schema.createTable('admin_users', (t) => { + t.increments('id'); + t.string('username'); + t.string('email'); + t.integer('role_id'); + t.boolean('is_active'); + t.timestamp('password_changed_at'); + }); + await mockDb.schema.createTable('permissions', (t) => { + t.increments('id'); + t.string('name'); + }); + await mockDb.schema.createTable('role_permissions', (t) => { + t.integer('role_id'); + t.integer('permission_id'); + }); + await mockDb('roles').insert([ + { id: 1, name: 'super_admin' }, + { id: 2, name: 'viewer' } + ]); + await mockDb('admin_users').insert([ + { + id: 1, + username: 'owner', + email: 'owner@example.test', + role_id: 1, + is_active: 1 + }, + { + id: 2, + username: 'viewer', + email: 'viewer@example.test', + role_id: 2, + is_active: 1 + } + ]); + await mockDb('permissions').insert({ id: 1, name: 'settings.edit' }); + await mockDb('role_permissions').insert({ role_id: 1, permission_id: 1 }); + app = express(); + app.use(express.json()); + app.use('/api/admin/usage', require('../../src/routes/adminUsage')); + app.use((err, _req, res, _next) => + res.status(err.statusCode || 500).json({ code: err.code }) + ); +}); +afterAll(() => mockDb.destroy()); +beforeEach(() => jest.clearAllMocks()); + +const ROUTES = [ + ['get', '/'], + ['post', '/activity'], + ['post', '/enable'], + ['post', '/disable'], + ['post', '/retry'], + ['post', '/dismiss'], + ['get', '/preview'], + ['get', '/export'], + ['put', '/feedback-preferences'], + ['post', '/feedback'], + ['post', '/vote'], + ['post', '/portal-session'] +]; +test.each(ROUTES)( + '%s %s rejects unauthenticated and gallery tokens', + async (method, route) => { + await request(app)[method](`/api/admin/usage${route}`).send({}).expect(401); + await request(app)[method](`/api/admin/usage${route}`) + .set('Authorization', `Bearer ${token('gallery')}`) + .send({}) + .expect(403); + expect(service.tick).not.toHaveBeenCalled(); + expect(service.enable).not.toHaveBeenCalled(); + } +); +test.each(ROUTES.filter(([, route]) => route !== '/activity'))( + '%s %s requires settings.edit', + async (method, route) => { + await request(app)[method](`/api/admin/usage${route}`) + .set('Authorization', `Bearer ${token('admin', 2)}`) + .send({}) + .expect(403); + } +); +test('an authenticated admin can trigger cadence without seeing identity or packet data', async () => { + const response = await request(app) + .post('/api/admin/usage/activity') + .set('Authorization', `Bearer ${token('admin', 2)}`) + .expect(200); + expect(response.body).toEqual({ ok: true }); + expect(service.tick).toHaveBeenCalledTimes(1); +}); +test('owner sees no-store status and supplies consent to the service', async () => { + await request(app) + .get('/api/admin/usage') + .set('Authorization', `Bearer ${token('admin')}`) + .expect('Cache-Control', 'no-store') + .expect(200); + await request(app) + .post('/api/admin/usage/enable') + .set('Authorization', `Bearer ${token('admin')}`) + .send({ consent_version: 'usage-consent.v1' }) + .expect(200); + expect(service.enable).toHaveBeenCalledWith('usage-consent.v1'); +}); +test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => { + const { EventEmitter } = require('events'); + const simulate = (path, admin, statusCode) => { + const res = new EventEmitter(); + res.statusCode = statusCode; + productUsage({ path, admin }, res, () => {}); + res.emit('finish'); + }; + simulate('/gallery/example', null, 200); + simulate('/customers', null, 200); + simulate('/quotes', { id: 1 }, 403); + expect(service.markUsed).not.toHaveBeenCalled(); + simulate('/customers/42/hour-entries', { id: 1 }, 200); + expect(service.markUsed).toHaveBeenCalledWith( + expect.arrayContaining(['crm', 'crm_hours']) + ); + expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42'); +}); diff --git a/backend/migrations/core/201_product_usage.js b/backend/migrations/core/201_product_usage.js new file mode 100644 index 00000000..b3c7dbf2 --- /dev/null +++ b/backend/migrations/core/201_product_usage.js @@ -0,0 +1,34 @@ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) { + await knex.schema.createTable('product_usage_state', (t) => { + t.integer('id').primary(); + t.string('status', 30).notNullable().defaultTo('disabled'); + t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.string('installation_id', 64); + t.string('public_key', 59); + t.text('private_key_encrypted'); + t.string('instance_binding', 64); + t.bigInteger('sequence').notNullable().defaultTo(0); + t.text('pending_packet'); + t.text('last_packet'); + t.text('last_receipt'); + t.string('last_report_date', 10); + t.string('last_error', 80); + t.text('feedback_preferences'); + t.string('lease_token', 36); + t.bigInteger('lease_until').notNullable().defaultTo(0); + }); + } + if (!(await knex('product_usage_state').where({ id: 1 }).first())) + await knex('product_usage_state').insert({ id: 1 }); + if (!(await knex.schema.hasTable('product_usage_markers'))) { + await knex.schema.createTable('product_usage_markers', (t) => { + t.string('feature', 60).primary(); + // A marker is only a capability name, never a timestamp or user/event ID. + }); + } +}; +exports.down = async function (knex) { + await knex.schema.dropTableIfExists('product_usage_markers'); + await knex.schema.dropTableIfExists('product_usage_state'); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index a495233a..2d8faaf3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,16 +1,17 @@ { "name": "picpeak-backend", - "version": "3.122.5-beta.0", + "version": "3.123.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "3.122.5-beta.0", + "version": "3.123.0-beta.0", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", + "ajv": "^8.20.0", "archiver": "^5.3.1", "axios": "1.18.1", "bcrypt": "6.0.0", @@ -1611,6 +1612,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", @@ -4006,16 +4031,15 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -5848,6 +5872,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/esm": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", @@ -6130,6 +6178,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz", @@ -8105,10 +8169,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -10805,6 +10868,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 99a437b1..ba117667 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", + "ajv": "^8.20.0", "archiver": "^5.3.1", "axios": "1.18.1", "bcrypt": "6.0.0", diff --git a/backend/server.js b/backend/server.js index 09c3f744..5e3df127 100644 --- a/backend/server.js +++ b/backend/server.js @@ -824,6 +824,8 @@ app.get( // Routes app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup) app.use('/api/auth', authRoutes); +app.use('/api/admin', require('./src/middleware/productUsage').productUsage); +app.use('/api/admin/usage', require('./src/routes/adminUsage')); app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia')); // Gallery routes - main routes first, then feedback routes app.use('/api/gallery', galleryRoutes); diff --git a/backend/src/middleware/productUsage.js b/backend/src/middleware/productUsage.js new file mode 100644 index 00000000..7bbd7eb7 --- /dev/null +++ b/backend/src/middleware/productUsage.js @@ -0,0 +1,50 @@ +// Only successful authenticated ADMIN capability operations set a coarse +// marker. Never mount on gallery/customer/public routes. No request values, +// identifiers, paths, timing, or counts are retained or sent. +const service = require('../services/productUsageService'); +const logger = require('../utils/logger'); +const RULES = [ + [/^\/customers(?:\/|$)/, ['crm']], + [/^\/quotes(?:\/|$)/, ['crm', 'crm_quotes']], + [/^\/invoices(?:\/|$)/, ['crm', 'crm_invoices']], + [/^\/contracts(?:\/|$)/, ['crm', 'crm_contracts']], + [/^\/projects(?:\/|$)/, ['crm', 'crm_projects']], + [/^\/calendar(?:\/|$)/, ['crm', 'crm_calendar']], + [/^\/customers\/(?:[^/]+\/)?hour-entries(?:\/|$)/, ['crm', 'crm_hours']], + [/^\/customers\/(?:invite|[^/]+\/send-invite)(?:\/|$)/, ['customer_portal']], + [ + /^\/(?:ledger|expenses|tax-report|incoming-invoices)(?:\/|$)/, + ['accounting'] + ], + [/^\/workflows(?:\/|$)/, ['workflows']], + [/^\/newsletters(?:\/|$)/, ['newsletters']], + [/^\/events\/[^/]+\/(?:faces|people)(?:\/|$)/, ['face_recognition']], + [/^\/whatsapp\/(?:send|test)(?:\/|$)/, ['whatsapp']], + [ + /^\/(?:backup|database-backup)\/(?:run|backup|create|start|test|picpeak\/export)(?:\/|$)/, + ['backup'] + ], + [/^\/backup\/s3\/test-upload(?:\/|$)/, ['s3_storage']], + [/^\/email\/(?:test|send)(?:\/|$)/, ['smtp']], + [/^\/external-media(?:\/|$)/, ['share_mounts']] +]; +function productUsage(req, res, next) { + const pathname = req.path; + res.once('finish', () => { + if (!req.admin?.id || res.statusCode < 200 || res.statusCode >= 300) return; + const features = RULES.filter(([pattern]) => + pattern.test(pathname) + ).flatMap(([, keys]) => keys); + if ( + process.env.STORAGE_BACKEND === 's3' && + /^\/(?:photos|events)\/[^/]+\/upload(?:\/|$)/.test(pathname) + ) + features.push('s3_storage'); + if (features.length) + service + .markUsed(features) + .catch(() => logger.warn('Product usage marker could not be recorded')); + }); + next(); +} +module.exports = { productUsage, RULES }; diff --git a/backend/src/routes/adminUsage.js b/backend/src/routes/adminUsage.js new file mode 100644 index 00000000..20cb1b38 --- /dev/null +++ b/backend/src/routes/adminUsage.js @@ -0,0 +1,118 @@ +const express = require('express'); +const crypto = require('crypto'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { ValidationError } = require('../utils/errors'); +const service = require('../services/productUsageService'); +const router = express.Router(); +const wrap = (fn) => (req, res, next) => + Promise.resolve(fn(req, res)).catch((error) => { + if (error.name === 'ProtocolError') + return res + .status(400) + .json({ error: 'Invalid usage request', code: error.code }); + next(error); + }); +router.use(adminAuth); +router.use((_req, res, next) => { + res.set('Cache-Control', 'no-store'); + next(); +}); +// Any authenticated admin can trigger the daily rollup; only settings editors +// see identity/packets or control consent. The route never accepts telemetry. +router.post( + '/activity', + wrap(async (_req, res) => { + try { + await service.tick(); + } catch (error) { + if (error.code !== 'CONFLICT') throw error; + } + res.json({ ok: true }); + }) +); +router.use(requirePermission('settings.edit')); +router.get( + '/', + wrap(async (_req, res) => res.json(await service.status())) +); +router.post( + '/dismiss', + wrap(async (_req, res) => res.json(await service.dismiss())) +); +router.post( + '/enable', + wrap(async (req, res) => + res.json(await service.enable(req.body.consent_version)) + ) +); +router.post( + '/disable', + wrap(async (_req, res) => res.json(await service.disable())) +); +router.post( + '/retry', + wrap(async (_req, res) => res.json(await service.tick())) +); +router.get( + '/preview', + wrap(async (_req, res) => res.json(await service.preview())) +); +router.get( + '/export', + wrap(async (_req, res) => + res.attachment('picpeak-usage-packets.json').json(await service.export()) + ) +); +router.put( + '/feedback-preferences', + wrap(async (req, res) => res.json(await service.preferences(req.body))) +); +router.post( + '/feedback', + wrap(async (req, res) => { + const body = req.body; + if ( + !body || + Object.keys(body).some( + (k) => + ![ + 'kind', + 'title', + 'body', + 'name', + 'allow_public', + 'allow_marketing' + ].includes(k) + ) || + typeof body.title !== 'string' || + !body.title.trim() || + typeof body.body !== 'string' || + !body.body.trim() + ) + throw new ValidationError('Invalid feedback'); + res.json( + await service.command('feedback', { + ...body, + feedback_id: crypto.randomUUID() + }) + ); + }) +); +router.post( + '/vote', + wrap(async (req, res) => res.json(await service.command('vote', req.body))) +); +router.post( + '/portal-session', + wrap(async (_req, res) => { + const result = await service.command('session', {}); + res.json({ + ...result, + url: result.receipt?.session_token + ? `${service.collectorUrl()}/#connect=${encodeURIComponent(result.receipt.session_token)}` + : null + }); + }) +); +module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 6eb4b704..7a5565c1 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1145,6 +1145,10 @@ router.get('/admin/sso/callback', async (req, res) => { await logActivity('admin_sso_login', { provider: 'oidc' }, null, { type: 'admin', id: admin.id, name: admin.username, }); + // Only a successful ADMIN SSO callback sets this opt-in capability marker. + // No token, claim, address, or account identifier reaches product usage. + require('../services/productUsageService').markUsed(['oauth']) + .catch(() => logger.warn('Product usage marker could not be recorded')); return res.redirect(`${frontendBase}/admin/dashboard`); } catch (error) { diff --git a/backend/src/services/productUsageService.js b/backend/src/services/productUsageService.js new file mode 100644 index 00000000..7441476a --- /dev/null +++ b/backend/src/services/productUsageService.js @@ -0,0 +1,3 @@ +const { db } = require('../database/db'); +const { UsageService } = require('../usage/UsageService'); +module.exports = new UsageService(db); diff --git a/backend/src/usage/UsageService.js b/backend/src/usage/UsageService.js new file mode 100644 index 00000000..95fb4fdc --- /dev/null +++ b/backend/src/usage/UsageService.js @@ -0,0 +1,639 @@ +'use strict'; +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); +const { getStoragePath } = require('../config/storage'); +const { formatBoolean } = require('../utils/dbCompat'); +const { + ConflictError, + ValidationError, + ServiceUnavailableError +} = require('../utils/errors'); +const { + generateIdentity, + makePacket, + signPacket, + verifyEnvelope, + digest, + canonical, + FEATURE_KEYS, + LAYOUTS +} = require('./protocol.cjs'); + +const FLAG_MAP = { + crm: 'clients', + crm_quotes: 'quotes', + crm_invoices: 'bills', + crm_contracts: 'contracts', + crm_projects: 'projects', + crm_calendar: 'calendar', + crm_hours: 'hoursLogging', + customer_portal: 'customerPortal', + accounting: 'accounting', + workflows: 'workflows', + newsletters: 'newsletters', + face_recognition: 'faces', + whatsapp: 'whatsapp' +}; +const SETTING_KEYS = [ + 'oidc_enabled', + 'oidc_issuer_url', + 'oidc_client_id', + 'backup_enabled', + 'backup_destination_type', + 'backup_s3_bucket', + 'theme_config', + 'general_custom_css', + 'general_public_site_custom_css' +]; +const truth = (value) => value === true || value === 1 || value === '1'; +const parse = (value) => { + try { + return JSON.parse(value); + } catch (_) { + return value; + } +}; + +class UsageService { + constructor(db, options = {}) { + this.db = db; + this.fetch = options.fetch || global.fetch; + this.now = options.now || (() => Date.now()); + this.version = options.version || require('../../package.json').version; + this.secret = + options.secret || + process.env.USAGE_ENCRYPTION_KEY || + process.env.JWT_SECRET; + this.endpoint = + options.endpoint || + process.env.USAGE_COLLECTOR_URL || + 'https://usage.picpeak.app'; + this.bindingPath = + options.bindingPath || path.join(getStoragePath(), 'usage-instance.key'); + this.encKey = null; + } + + collectorUrl() { + const url = new URL(this.endpoint); + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if ( + url.username || + url.password || + url.search || + url.hash || + url.pathname !== '/' || + (url.protocol !== 'https:' && + !( + url.protocol === 'http:' && + loopback && + process.env.NODE_ENV !== 'production' + )) + ) { + throw new ValidationError('Invalid usage collector URL'); + } + return url.origin; + } + key() { + if (!this.secret || this.secret.length < 32) + throw new ServiceUnavailableError( + 'Usage signing-key encryption is not configured' + ); + if (!this.encKey) + this.encKey = crypto.scryptSync( + this.secret, + 'picpeak-product-usage-v1', + 32 + ); + return this.encKey; + } + encrypt(value) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', this.key(), iv); + const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + return [iv, cipher.getAuthTag(), data] + .map((v) => v.toString('base64url')) + .join('.'); + } + decrypt(value) { + const [iv, tag, data] = value + .split('.') + .map((v) => Buffer.from(v, 'base64url')); + const cipher = crypto.createDecipheriv('aes-256-gcm', this.key(), iv); + cipher.setAuthTag(tag); + return Buffer.concat([cipher.update(data), cipher.final()]).toString( + 'utf8' + ); + } + async binding(create = false) { + if (create) { + await fs.mkdir(path.dirname(this.bindingPath), { recursive: true }); + try { + await fs.writeFile( + this.bindingPath, + crypto.randomBytes(32).toString('hex'), + { flag: 'wx', mode: 0o600 } + ); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + } + try { + return digest(await fs.readFile(this.bindingPath)); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + } + async state() { + return this.db('product_usage_state').where({ id: 1 }).first(); + } + async status() { + const state = await this.state(); + return { + status: state.status, + notice_dismissed: Boolean(state.notice_dismissed), + installation_id: state.installation_id, + collector_url: this.collectorUrl(), + schema_version: 'usage.v1', + last_report_date: state.last_report_date, + last_error: state.last_error, + pending_action: state.pending_packet + ? JSON.parse(state.pending_packet).action + : null, + last_packet: state.last_packet ? JSON.parse(state.last_packet) : null, + feedback_preferences: state.feedback_preferences + ? JSON.parse(state.feedback_preferences) + : { name: '' } + }; + } + + async locked(fn) { + const token = crypto.randomUUID(); + const updated = await this.db('product_usage_state') + .where({ id: 1 }) + .where('lease_until', '<=', this.now()) + .update({ lease_token: token, lease_until: this.now() + 60000 }); + if (!updated) + throw new ConflictError('Usage operation is already in progress'); + try { + return await fn(await this.state()); + } finally { + await this.db('product_usage_state') + .where({ id: 1, lease_token: token }) + .update({ lease_token: null, lease_until: 0 }); + } + } + + async dismiss() { + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ notice_dismissed: formatBoolean(true) }); + return this.status(); + } + async enable(consent) { + if (consent !== 'usage-consent.v1') + throw new ValidationError('Explicit usage consent is required'); + await this.locked(async (state) => { + if (state.status !== 'disabled') + throw new ConflictError( + 'Finish the current participation before rejoining' + ); + this.collectorUrl(); + const identity = generateIdentity(); + const pending = makePacket(identity, 'register', 0, { + consent_version: consent + }); + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ + status: 'activation_pending', + notice_dismissed: formatBoolean(true), + installation_id: identity.installation_id, + public_key: identity.public_key, + private_key_encrypted: this.encrypt(identity.private_key), + instance_binding: await this.binding(true), + sequence: 0, + pending_packet: JSON.stringify(pending), + last_error: null + }); + await this.deliver(await this.state()); + }); + return this.status(); + } + + async disable() { + // Stop collection before waiting for an in-flight send. The sender checks + // state again before delivery and preserves this stop after its response. + await this.db('product_usage_state') + .where({ id: 1 }) + .whereNot({ status: 'disabled' }) + .update({ + status: 'deletion_pending', + feedback_preferences: null, + pending_packet: null, + last_packet: null, + last_receipt: null, + last_report_date: null + }); + await this.db('product_usage_markers').delete(); + try { + await this.tick(); + } catch (error) { + // A sender may still own the lease. Collection is already stopped and + // the next admin activity retries deletion after that sender finishes. + if (error.code !== 'CONFLICT') throw error; + } + return this.status(); + } + + async post(pathname, body, maxResponseBytes = 65536) { + const response = await this.fetch(`${this.collectorUrl()}${pathname}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + redirect: 'error', + signal: AbortSignal.timeout(10000) + }); + if (Number(response.headers.get('content-length') || 0) > maxResponseBytes) + throw new ServiceUnavailableError('Invalid collector response'); + let raw = ''; + let bytes = 0; + const decoder = new TextDecoder(); + for await (const chunk of response.body) { + bytes += chunk.length; + if (bytes > maxResponseBytes) + throw new ServiceUnavailableError('Invalid collector response'); + raw += decoder.decode(chunk, { stream: true }); + } + raw += decoder.decode(); + const value = JSON.parse(raw); + if (!response.ok) { + const error = new Error('Collector rejected operation'); + error.code = value.error; + throw error; + } + return value; + } + async deliver(state) { + const packet = JSON.parse(state.pending_packet); + if ( + packet.action !== 'delete' && + (await this.state()).status === 'deletion_pending' + ) + return null; + try { + if ( + packet.action !== 'delete' && + state.instance_binding !== (await this.binding()) + ) { + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ + status: 'identity_conflict', + last_error: 'INSTANCE_COPY_DETECTED' + }); + return null; + } + const envelope = signPacket( + packet, + { + public_key: state.public_key, + private_key: this.decrypt(state.private_key_encrypted) + }, + new Date(this.now()) + ); + const receipt = await this.post('/api/envelopes', envelope); + if ( + receipt.packet_id !== packet.packet_id || + receipt.installation_id !== packet.installation_id || + receipt.packet_digest !== digest(canonical(packet)) || + receipt.action !== packet.action || + receipt.sequence !== packet.sequence || + receipt.status !== (packet.action === 'delete' ? 'deleted' : 'accepted') + ) { + throw new Error('Invalid collector receipt'); + } + if (packet.action === 'delete') { + await fs.unlink(this.bindingPath).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + await this.db('product_usage_markers').delete(); + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ + status: 'disabled', + installation_id: null, + public_key: null, + private_key_encrypted: null, + instance_binding: null, + pending_packet: null, + last_packet: null, + last_receipt: null, + last_report_date: null, + last_error: null, + sequence: 0, + feedback_preferences: null + }); + } else { + const update = { + sequence: packet.sequence, + pending_packet: null, + last_error: null, + last_receipt: JSON.stringify(receipt) + }; + if (packet.action === 'report') { + update.last_packet = JSON.stringify(envelope); + update.last_report_date = packet.payload.report_date; + } + await this.db('product_usage_state') + .where({ id: 1 }) + .whereNot({ status: 'deletion_pending' }) + .update(update); + await this.db('product_usage_state') + .where({ id: 1, status: 'deletion_pending' }) + .update({ sequence: packet.sequence, pending_packet: null }); + if (packet.action === 'register') + await this.db('product_usage_state') + .where({ id: 1, status: 'activation_pending' }) + .update({ status: 'active' }); + } + return receipt; + } catch (error) { + const rejected = [ + 'INVALID_PACKET', + 'INVALID_REPORT_DATE', + 'INVALID_PUBLICATION_CONSENT', + 'REQUEST_NOT_FOUND', + 'FEEDBACK_CONFLICT' + ].includes(error.code); + if (rejected && ['feedback', 'vote', 'session'].includes(packet.action)) { + await this.db('product_usage_state') + .where({ id: 1 }) + .whereNot({ status: 'deletion_pending' }) + .update({ pending_packet: null, last_error: 'REQUEST_REJECTED' }); + return null; + } + const conflict = [ + 'SEQUENCE_CONFLICT', + 'IDENTITY_CONFLICT', + 'IDENTITY_REVOKED', + 'NOT_REGISTERED', + 'PACKET_CONFLICT' + ].includes(error.code); + const code = conflict ? error.code : 'DELIVERY_FAILED'; + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ last_error: code }); + if (conflict && packet.action !== 'delete') { + await this.db('product_usage_state') + .where({ id: 1 }) + .whereNot({ status: 'deletion_pending' }) + .update({ status: 'identity_conflict' }); + } + return null; + } + } + + async tick() { + await this.locked(async (state) => { + if (state.status === 'disabled') return; + if (state.status === 'deletion_pending') { + const packet = makePacket(state, 'delete', Number(state.sequence), {}); + state.pending_packet = JSON.stringify(packet); + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ pending_packet: state.pending_packet }); + await this.deliver(state); + return; + } + if (state.status === 'identity_conflict') return; + if (state.pending_packet) { + await this.deliver(state); + state = await this.state(); + } + if (state.status !== 'active' || state.pending_packet) return; + if ( + state.last_report_date === + new Date(this.now()).toISOString().slice(0, 10) + ) + return; + const payload = await this.snapshot(); + const packet = makePacket( + state, + 'report', + Number(state.sequence) + 1, + payload + ); + state.pending_packet = JSON.stringify(packet); + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ pending_packet: state.pending_packet }); + await this.deliver(state); + }); + return this.status(); + } + + async markUsed(features) { + const allowed = [...new Set(features)].filter((f) => + FEATURE_KEYS.includes(f) + ); + if (!allowed.length) return; + // Single-transaction status check prevents opt-out racing a late marker. + await this.db.transaction(async (tx) => { + const query = tx('product_usage_state').where({ id: 1 }); + if (this.db.client.config.client === 'pg') query.forUpdate(); + const state = await query.first(); + if (!state || state.status !== 'active') return; + if (allowed.includes('backup')) { + const destination = await tx('app_settings') + .where({ setting_key: 'backup_destination_type' }) + .first(); + if (destination && parse(destination.setting_value) === 's3') + allowed.push('s3_storage'); + } + await tx('product_usage_markers') + .insert(allowed.map((feature) => ({ feature }))) + .onConflict('feature') + .ignore(); + }); + } + + async snapshot() { + const rows = await this.db('app_settings') + .whereIn('setting_key', SETTING_KEYS) + .select('setting_key', 'setting_value'); + const settings = Object.fromEntries( + rows.map((r) => [r.setting_key, parse(r.setting_value)]) + ); + const flagRows = await this.db('feature_flags') + .whereIn('key', Object.values(FLAG_MAP)) + .select('key', 'value'); + const flags = Object.fromEntries( + flagRows.map((r) => [r.key, truth(r.value)]) + ); + const used = new Set( + await this.db('product_usage_markers').pluck('feature') + ); + const features = Object.fromEntries( + FEATURE_KEYS.map((key) => [ + key, + { configured: Boolean(flags[FLAG_MAP[key]]), used: used.has(key) } + ]) + ); + features.oauth.configured = + truth(settings.oidc_enabled) && + Boolean(settings.oidc_issuer_url && settings.oidc_client_id); + features.backup.configured = truth(settings.backup_enabled); + features.s3_storage.configured = + (settings.backup_destination_type === 's3' && + Boolean(settings.backup_s3_bucket)) || + (process.env.STORAGE_BACKEND === 's3' && + Boolean( + process.env.STORAGE_S3_BUCKET && + process.env.STORAGE_S3_ACCESS_KEY && + process.env.STORAGE_S3_SECRET_KEY + )); + features.share_mounts.configured = Boolean( + await this.db('events') + .whereNotNull('external_path') + .whereNot('external_path', '') + .select('id') + .first() + ); + features.smtp.configured = + Boolean( + await this.db('email_configs') + .whereNotNull('smtp_host') + .whereNot('smtp_host', '') + .select('id') + .first() + ) || + Boolean( + await this.db('mail_accounts') + .whereNotNull('smtp_host') + .whereNot('smtp_host', '') + .select('id') + .first() + ); + features.whatsapp.configured = + features.whatsapp.configured && + Boolean( + await this.db('whatsapp_configs') + .where({ enabled: formatBoolean(true) }) + .whereNot('phone_number_id', '') + .whereNot('access_token', '') + .select('id') + .first() + ); + const theme = settings.theme_config || {}; + features.custom_css.configured = Boolean( + settings.general_custom_css || + settings.general_public_site_custom_css || + theme.customCss + ); + // Read only the theme field, never event names, IDs, sizes, counts, or photos. + const themes = await this.db('events').distinct('color_theme'); + const layouts = new Set(); + for (const row of themes) { + const value = parse(row.color_theme); + const layout = + value && typeof value === 'object' + ? value.galleryLayout || 'grid' + : 'grid'; + layouts.add(LAYOUTS.includes(layout) ? layout : 'other'); + if (value && typeof value === 'object' && value.customCss) + features.custom_css.configured = true; + } + // Applied CSS is already a capability in use; no visitor observation is + // needed. Remember its presence as a coarse lifetime marker after consent. + if (features.custom_css.configured) { + await this.markUsed(['custom_css']); + features.custom_css.used = true; + } + const now = new Date(this.now()).toISOString(); + return { + picpeak_version: this.version, + report_date: now.slice(0, 10), + generated_at: now, + features, + gallery_layouts: [...layouts].sort() + }; + } + + async preview() { + const state = await this.state(); + if (state.status !== 'active') + throw new ConflictError('Usage participation is not active'); + return this.snapshot(); + } + async command(action, payload) { + let receipt; + await this.locked(async (state) => { + if (state.status !== 'active') + throw new ConflictError('Usage participation is not active'); + if (state.pending_packet) + throw new ConflictError('Retry the pending usage operation first'); + if (!['feedback', 'vote', 'session'].includes(action)) + throw new ValidationError('Invalid usage action'); + const packet = makePacket( + state, + action, + Number(state.sequence) + 1, + payload + ); + // Validate the complete packet before storing an un-sendable operation. + verifyEnvelope( + signPacket( + packet, + { + public_key: state.public_key, + private_key: this.decrypt(state.private_key_encrypted) + }, + new Date(this.now()) + ), + this.now() + ); + state.pending_packet = JSON.stringify(packet); + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ pending_packet: state.pending_packet }); + receipt = await this.deliver(state); + }); + const state = await this.status(); + return { + delivered: Boolean(receipt), + queued: Boolean(state.pending_action), + receipt, + state + }; + } + async preferences(value) { + if ( + !value || + Object.keys(value).some((k) => k !== 'name') || + typeof value.name !== 'string' || + value.name.length > 80 + ) + throw new ValidationError('Invalid feedback preferences'); + const updated = await this.db('product_usage_state') + .where({ id: 1, status: 'active' }) + .update({ + feedback_preferences: JSON.stringify({ name: value.name.trim() }) + }); + if (!updated) throw new ConflictError('Usage participation is not active'); + return this.status(); + } + async export() { + const state = await this.state(); + if (!state.installation_id) throw new ConflictError('No usage identity'); + // Own-data export includes the complete retained history, not a truncated + // packet subset. The acceptance/receipt path above stays strictly bounded. + return this.post( + '/api/participant/lookup', + { installation_id: state.installation_id }, + Infinity + ); + } +} +module.exports = { UsageService, FLAG_MAP }; diff --git a/backend/src/usage/protocol.cjs b/backend/src/usage/protocol.cjs new file mode 100644 index 00000000..1547cea6 --- /dev/null +++ b/backend/src/usage/protocol.cjs @@ -0,0 +1,155 @@ +"use strict"; +const crypto = require("node:crypto"); +const Ajv = require("ajv"); +const { + envelopeSchema, + FEATURE_KEYS, + LAYOUTS, + payloads, +} = require("./schema.cjs"); +const validate = new Ajv({ allErrors: false, strict: true }).compile( + envelopeSchema, +); +const MAX_BYTES = 16384; +const MAX_AGE_MS = 5 * 60 * 1000; + +class ProtocolError extends Error { + constructor(code, status = 400) { + super(code); + this.code = code; + this.status = status; + } +} +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} +const digest = (value) => + crypto.createHash("sha256").update(value).digest("hex"); +function publicKeyIdentity(publicKey) { + const bytes = Buffer.from(publicKey, "base64url"); + if (bytes.toString("base64url") !== publicKey || bytes.length !== 44) + throw new ProtocolError("INVALID_KEY"); + const key = crypto.createPublicKey({ + key: bytes, + format: "der", + type: "spki", + }); + if (key.asymmetricKeyType !== "ed25519") + throw new ProtocolError("INVALID_KEY"); + return { key, id: digest(bytes) }; +} +function generateIdentity() { + const keys = crypto.generateKeyPairSync("ed25519"); + const public_key = keys.publicKey + .export({ format: "der", type: "spki" }) + .toString("base64url"); + return { + installation_id: publicKeyIdentity(public_key).id, + public_key, + private_key: keys.privateKey.export({ format: "pem", type: "pkcs8" }), + }; +} +function makePacket(identity, action, sequence, payload) { + return { + schema_version: "usage.v1", + installation_id: identity.installation_id, + packet_id: crypto.randomUUID(), + action, + sequence, + payload, + }; +} +function signPacket(packet, identity, now = new Date()) { + const signed = { + packet, + public_key: identity.public_key, + issued_at: now.toISOString(), + nonce: crypto.randomUUID(), + }; + const signature = crypto + .sign(null, Buffer.from(canonical(signed)), identity.private_key) + .toString("base64url"); + const envelope = { ...signed, signature }; + if ( + !validate(envelope) || + Buffer.byteLength(JSON.stringify(envelope)) > MAX_BYTES + ) + throw new ProtocolError("INVALID_PACKET"); + return envelope; +} +function verifyEnvelope(envelope, now = Date.now()) { + if ( + Buffer.byteLength(JSON.stringify(envelope) || "") > MAX_BYTES || + !validate(envelope) + ) + throw new ProtocolError("INVALID_PACKET"); + const issued = Date.parse(envelope.issued_at); + if ( + !Number.isFinite(issued) || + new Date(issued).toISOString() !== envelope.issued_at || + Math.abs(now - issued) > MAX_AGE_MS + ) { + throw new ProtocolError("EXPIRED_SIGNATURE", 401); + } + let identity; + try { + identity = publicKeyIdentity(envelope.public_key); + } catch (_) { + throw new ProtocolError("INVALID_KEY", 401); + } + if (identity.id !== envelope.packet.installation_id) + throw new ProtocolError("IDENTITY_MISMATCH", 401); + const { signature, ...signed } = envelope; + const signatureBytes = Buffer.from(signature, "base64url"); + if ( + signatureBytes.toString("base64url") !== signature || + !crypto.verify( + null, + Buffer.from(canonical(signed)), + identity.key, + signatureBytes, + ) + ) + throw new ProtocolError("INVALID_SIGNATURE", 401); + const { action, payload } = envelope.packet; + if (action === "report") { + const generated = Date.parse(payload.generated_at); + if ( + !Number.isFinite(generated) || + new Date(generated).toISOString() !== payload.generated_at || + payload.report_date !== payload.generated_at.slice(0, 10) || + generated > now + MAX_AGE_MS + ) + throw new ProtocolError("INVALID_REPORT_DATE"); + } + if ( + action === "feedback" && + payload.allow_marketing && + (payload.kind !== "testimonial" || !payload.allow_public) + ) { + throw new ProtocolError("INVALID_PUBLICATION_CONSENT"); + } + return envelope.packet; +} +module.exports = { + canonical, + digest, + generateIdentity, + makePacket, + signPacket, + verifyEnvelope, + ProtocolError, + MAX_BYTES, + MAX_AGE_MS, + FEATURE_KEYS, + LAYOUTS, + envelopeSchema, + payloads, +}; diff --git a/backend/src/usage/schema.cjs b/backend/src/usage/schema.cjs new file mode 100644 index 00000000..3459a371 --- /dev/null +++ b/backend/src/usage/schema.cjs @@ -0,0 +1,138 @@ +"use strict"; + +// Vendored unchanged in PicPeak. Changing the wire contract requires a new +// schema version and matching conformance tests in both repositories. +const FEATURE_KEYS = [ + "crm", + "crm_quotes", + "crm_invoices", + "crm_contracts", + "crm_projects", + "crm_calendar", + "crm_hours", + "customer_portal", + "accounting", + "workflows", + "newsletters", + "face_recognition", + "custom_css", + "oauth", + "smtp", + "whatsapp", + "backup", + "s3_storage", + "share_mounts", +]; +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, +}); +const uuid = { + type: "string", + pattern: + "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", +}; +const hash = { type: "string", pattern: "^[0-9a-f]{64}$" }; +const timestamp = { + type: "string", + pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", +}; +const text = (maxLength, minLength = 1) => ({ + type: "string", + minLength, + maxLength, +}); +const boolean = { type: "boolean" }; +const features = object( + Object.fromEntries( + FEATURE_KEYS.map((key) => [ + key, + object({ configured: boolean, used: boolean }), + ]), + ), +); +const report = object({ + picpeak_version: { + type: "string", + maxLength: 48, + pattern: "^\\d+\\.\\d+\\.\\d+(?:-(?:alpha|beta|rc)\\.\\d+)?$", + }, + report_date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, + generated_at: timestamp, + features, + gallery_layouts: { + type: "array", + uniqueItems: true, + maxItems: LAYOUTS.length, + items: { enum: LAYOUTS }, + }, +}); +const feedback = object({ + feedback_id: uuid, + kind: { enum: ["feedback", "feature_request", "testimonial"] }, + title: text(120), + body: text(4000), + name: text(80, 0), + allow_public: boolean, + allow_marketing: boolean, +}); +const payloads = { + register: object({ consent_version: { const: "usage-consent.v1" } }), + report, + delete: object({}), + feedback, + vote: object({ feedback_id: uuid, voted: boolean }), + session: object({}), +}; +const packetBase = { + schema_version: { const: "usage.v1" }, + installation_id: hash, + packet_id: uuid, + sequence: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, +}; +const packetSchema = { + oneOf: Object.entries(payloads).map(([action, payload]) => + object({ + ...packetBase, + action: { const: action }, + payload, + }), + ), +}; +const envelopeSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + $id: "https://usage.picpeak.app/schema/usage.v1.json", + title: "PicPeak usage.v1 signed envelope", + description: + "Only report.payload is automatic feature telemetry. Other actions are explicit participant operations. See /transparency for field semantics and retention.", + ...object({ + packet: packetSchema, + public_key: { + type: "string", + minLength: 59, + maxLength: 59, + pattern: "^[A-Za-z0-9_-]+$", + }, + issued_at: timestamp, + nonce: uuid, + signature: { + type: "string", + minLength: 86, + maxLength: 86, + pattern: "^[A-Za-z0-9_-]+$", + }, + }), +}; +module.exports = { FEATURE_KEYS, LAYOUTS, envelopeSchema, payloads }; diff --git a/docker-compose.yml b/docker-compose.yml index 4ebb703a..2ff5b814 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,9 @@ services: # JWT_SECRET) and the break-glass override that re-enables local # password login when the IdP is down while SSO-only mode is active. - OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-} + # Optional product usage (#1110); remains off until explicit in-app consent. + - USAGE_COLLECTOR_URL=${USAGE_COLLECTOR_URL:-https://usage.picpeak.app} + - USAGE_ENCRYPTION_KEY=${USAGE_ENCRYPTION_KEY:-} - OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-} - ADMIN_URL=${ADMIN_URL:-} - TZ=${TZ:-UTC} diff --git a/docs/PRODUCT_USAGE.md b/docs/PRODUCT_USAGE.md new file mode 100644 index 00000000..457fb84f --- /dev/null +++ b/docs/PRODUCT_USAGE.md @@ -0,0 +1,81 @@ +# Optional product usage and feedback (#1110) + +Tracking is disabled by default. After updating, settings editors see a +dismissible invitation in the admin shell. Only explicit consent in Settings → +Product usage & feedback registers an installation. Public galleries never +load the usage UI chunk or trigger product reports. + +The backend builds and signs allowlisted feature packets, sending them at most +daily on authenticated admin use. It does not send gallery visitors, click +histories, photo/gallery counts, names, emails, domains, filenames, or secrets. +The settings page provides disclosure, raw preview/export, a private lookup +hash, delivery status, feedback, and a short-lived voting connection. + +## Deployment + +Run the normal core migrations; migration 201 creates dedicated state/marker +tables. No identity or key is created by the migration. No extra browser +tracker or CORS policy is required. The collector is the separate +`picpeak-usage` app from [#1110](https://github.com/PicPeak/picpeak/issues/1110). + +| Backend variable | Default | Meaning | +| --- | --- | --- | +| `USAGE_COLLECTOR_URL` | https://usage.picpeak.app | Fixed operator-configured collector origin, HTTPS in production | +| `USAGE_ENCRYPTION_KEY` | JWT_SECRET | 32+ characters, encrypts the local Ed25519 key with AES-256-GCM | + +Local development can use an HTTP loopback collector outside production. The +collector URL is never writable through generic settings or request payloads. +Keep the encryption material stable and protected; losing it makes the old +identity unable to sign deletion requests. Keys live in a dedicated database +table, not the generic readable settings. A random mode-0600 file at +`getStoragePath()/usage-instance.key` binds the database to its local storage. + +## Consent and deletion lifecycle + +Disabled → activation pending → active. Registration/delivery failures are +durable and retried. Multiple admin tabs/processes share a database lease; +only accepted receipts advance the sequence and report date. Re-signed retries +reuse the immutable packet ID so lost acknowledgements do not duplicate data. + +Opt-out immediately stops collection, clears markers/previews/feedback +preferences, and enters deletion pending. It keeps only credentials and the +deletion operation until the collector confirms deletion. The collector removes +reports, projections, feedback/publications, votes, and sessions. PicPeak then +erases the local fingerprint, private key and binding. A later join generates +a fresh identity. Repeated deletion handles lost receipts safely. + +A missing/mismatched storage binding or conflicting collector sequence stops +reporting with identity conflict. A full clone of a signing identity cannot be +distinguished cryptographically. Do not run the same participation identity in +two deployments; disable/delete the old participation and rejoin. Deletion +affects any other copy that shared the same identity. + +## Feedback and permissions + +Only settings.edit can inspect identity/packets or change participation and +feedback preferences. Any authenticated admin may trigger the fixed daily +report; the activity endpoint accepts no telemetry input. Every usage endpoint +uses adminAuth, including token-type checks. Gallery tokens cannot use it. + +Feedback is sent only on explicit submission. Each item defaults anonymous and +private; names, publication permission, and testimonial marketing permission +are separate choices. Published requests/testimonials require maintainer review. +Public voting uses a backend-authorized 15-minute session, never the lookup hash. + +## Contract + +The closed schema is in `backend/src/usage/schema.cjs`, with signing in +`protocol.cjs`. Keep both byte-identical to the collector's `protocol/` copies. +The collector serves the schema, complete source archive, public projections, +and full raw exports. Feature semantics and retention are documented in its +`docs/PROTOCOL.md` and `docs/OPERATIONS.md`. + +Used flags represent successful allowlisted admin capability calls since +joining, not visitor behavior or counts. OAuth marks successful admin SSO; +applied CSS is observed during report generation. Gallery layouts are controlled +enums extracted from event themes without IDs or counts. Other signals use the +explicit rules in `middleware/productUsage.js` and `usage/UsageService.js`. + +Tests: `backend/__tests__/routes/adminUsage.test.js`, frontend +`features/settings/__tests__/ProductUsageTab.test.tsx`, and the collector's +cross-repository integration suite with isolated databases and real HTTP. diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 6d7f65d8..34804422 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { lazy, Suspense, useState } from 'react'; import { Outlet, Navigate } from 'react-router-dom'; import { useAdminAuth } from '../../contexts'; @@ -11,6 +11,7 @@ import { MigrationBanner } from './MigrationBanner'; import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal'; const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed'; +const ProductUsageNotice = lazy(() => import('./ProductUsageNotice')); export const AdminLayout: React.FC = () => { const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth(); @@ -125,6 +126,7 @@ const AdminLayoutInner: React.FC = ({ sidebarOpen, setSid (or remove this mount) after operators have had time to update their docker-compose.yml. See #669. */} + {!mustChangePassword && } {/* Page content - disabled when password change required. overflow moved up to the column so the scrollbar gutter is @@ -138,4 +140,4 @@ const AdminLayoutInner: React.FC = ({ sidebarOpen, setSid ); }; -AdminLayout.displayName = 'AdminLayout'; \ No newline at end of file +AdminLayout.displayName = 'AdminLayout'; diff --git a/frontend/src/components/admin/ProductUsageNotice.tsx b/frontend/src/components/admin/ProductUsageNotice.tsx new file mode 100644 index 00000000..922c8feb --- /dev/null +++ b/frontend/src/components/admin/ProductUsageNotice.tsx @@ -0,0 +1,69 @@ +import { useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { usePermissions } from '../../contexts/PermissionsContext'; +import { productUsageService } from '../../services/productUsage.service'; + +// Loaded only inside the authenticated admin tree. Gallery routes never import +// this chunk, make usage requests, or record product usage markers. +export default function ProductUsageNotice() { + const { t } = useTranslation(); + const { hasPermission } = usePermissions(); + const queryClient = useQueryClient(); + const { data } = useQuery({ + queryKey: ['productUsage'], + queryFn: productUsageService.status, + enabled: hasPermission('settings.edit') + }); + useEffect(() => { + let running = false; + const tick = async () => { + if (document.visibilityState === 'hidden' || running) return; + running = true; + try { + await productUsageService.activity(); + } catch { + /* Best-effort delivery; settings show persisted retry state. */ + } finally { + running = false; + } + }; + void tick(); + const timer = window.setInterval(tick, 5 * 60 * 1000); + document.addEventListener('visibilitychange', tick); + return () => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', tick); + }; + }, []); + if (!hasPermission('settings.edit') || !data || data.status !== 'disabled' || data.notice_dismissed) return null; + return ( + + ); +} diff --git a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx new file mode 100644 index 00000000..9e80b768 --- /dev/null +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -0,0 +1,153 @@ +import { + render, + screen, + fireEvent, + waitFor, + cleanup +} from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; +import ProductUsageTab from '../tabs/ProductUsageTab'; +import { + productUsageService as service, + type UsageStatus +} from '../../../services/productUsage.service'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})); +vi.mock('../../../components/common/ConfirmDialog', () => ({ + useConfirm: () => async () => true +})); +vi.mock('../../../services/productUsage.service', () => ({ + productUsageService: { + status: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + retry: vi.fn(), + preview: vi.fn(), + export: vi.fn(), + preferences: vi.fn(), + feedback: vi.fn(), + portalSession: vi.fn() + } +})); +const status: UsageStatus = { + status: 'disabled', + notice_dismissed: false, + installation_id: null, + collector_url: 'https://usage.picpeak.app', + schema_version: 'usage.v1', + last_report_date: null, + last_error: null, + pending_action: null, + last_packet: null, + feedback_preferences: { name: 'Remembered private name' } +}; +const mount = () => + render( + + + + ); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(service.status).mockResolvedValue({ ...status }); + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute('open', ''); + }; +}); +afterEach(cleanup); +describe('product usage controls', () => { + it('requires the disclosure and an unchecked-by-default consent before enabling', async () => { + mount(); + fireEvent.click(await screen.findByText('productUsage.review')); + const enable = screen.getByRole('button', { name: 'productUsage.enable' }); + expect(enable).toBeDisabled(); + for (const key of [ + 'fields', + 'excluded', + 'transport', + 'visibility', + 'deletion', + 'feedbackDisclosure' + ]) + expect(screen.getByText(`productUsage.${key}`)).toBeInTheDocument(); + expect(service.enable).not.toHaveBeenCalled(); + expect(service.preview).not.toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText('productUsage.consentCheck')); + fireEvent.click(enable); + await waitFor(() => expect(service.enable).toHaveBeenCalledTimes(1)); + }); + it('sends anonymous private feedback even when a remembered name exists', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'active', + installation_id: 'a'.repeat(64) + }); + vi.mocked(service.feedback).mockResolvedValue({ + delivered: true, + state: { ...status, status: 'active' } + }); + mount(); + await screen.findByText('productUsage.feedbackTitle'); + expect(screen.getByLabelText('productUsage.includeName')).not.toBeChecked(); + fireEvent.change(screen.getByLabelText('productUsage.subject'), { + target: { value: 'Feedback title' } + }); + fireEvent.change(screen.getByLabelText('productUsage.message'), { + target: { value: 'Useful details' } + }); + fireEvent.click( + screen.getByRole('button', { name: 'productUsage.sendFeedback' }) + ); + await waitFor(() => + expect(service.feedback).toHaveBeenCalledWith({ + kind: 'feedback', + title: 'Feedback title', + body: 'Useful details', + name: '', + allow_public: false, + allow_marketing: false + }) + ); + }); + it('requires a separate marketing choice and clears it when publication permission is removed', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'active' + }); + mount(); + fireEvent.change(await screen.findByLabelText('productUsage.kind'), { + target: { value: 'testimonial' } + }); + expect(screen.getByLabelText('productUsage.allowPublic')).not.toBeChecked(); + expect(screen.getByLabelText('productUsage.allowMarketing')).toBeDisabled(); + fireEvent.click(screen.getByLabelText('productUsage.allowPublic')); + fireEvent.click(screen.getByLabelText('productUsage.allowMarketing')); + expect(screen.getByLabelText('productUsage.allowMarketing')).toBeChecked(); + fireEvent.click(screen.getByLabelText('productUsage.allowPublic')); + expect( + screen.getByLabelText('productUsage.allowMarketing') + ).not.toBeChecked(); + }); + it('keeps deletion pending explicit and offers retry without rejoining or sending feedback', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'deletion_pending', + last_error: 'DELIVERY_FAILED' + }); + mount(); + await screen.findByText('productUsage.states.deletion_pending'); + expect(screen.queryByText('productUsage.review')).not.toBeInTheDocument(); + expect( + screen.queryByText('productUsage.feedbackTitle') + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'productUsage.retry' })); + await waitFor(() => expect(service.retry).toHaveBeenCalled()); + }); +}); diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx new file mode 100644 index 00000000..b4d42a1c --- /dev/null +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -0,0 +1,428 @@ +import { useEffect, useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + productUsageService as service, + type ProductFeedback +} from '../../../services/productUsage.service'; +import { useConfirm } from '../../../components/common/ConfirmDialog'; +import { Button } from '../../../components/common/Button'; + +function ConsentDialog({ + close, + enable, + busy, + collector +}: { + close: () => void; + enable: () => void; + busy: boolean; + collector: string; +}) { + const { t } = useTranslation(); + const ref = useRef(null); + const [checked, setChecked] = useState(false); + useEffect(() => { + ref.current?.showModal(); + }, []); + return ( + + +
+ {[ + 'purpose', + 'fields', + 'excluded', + 'transport', + 'visibility', + 'deletion', + 'feedbackDisclosure' + ].map((key) => ( +

{t(`productUsage.${key}`, { collector })}

+ ))} +
+ +
+ + +
+
+ ); +} + +export default function ProductUsageTab() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const confirm = useConfirm(); + const { data, isPending, isError } = useQuery({ + queryKey: ['productUsage'], + queryFn: service.status, + refetchInterval: 30000 + }); + const [consent, setConsent] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const [preview, setPreview] = useState(null); + const [portalUrl, setPortalUrl] = useState(null); + const [named, setNamed] = useState(false); + const [form, setForm] = useState({ + kind: 'feedback', + title: '', + body: '', + name: '', + allow_public: false, + allow_marketing: false + }); + const run = async (fn: () => Promise) => { + setBusy(true); + setMessage(''); + try { + await fn(); + } catch { + setMessage(t('productUsage.failed')); + } finally { + setBusy(false); + await queryClient.invalidateQueries({ queryKey: ['productUsage'] }); + } + }; + const download = (value: unknown) => { + const url = URL.createObjectURL( + new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }) + ); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'picpeak-usage-packets.json'; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + if (isPending) return

{t('productUsage.loading')}

; + if (isError || !data) return

{t('productUsage.failed')}

; + const active = data.status === 'active'; + return ( +
+

{t('productUsage.purpose')}

+
+

+ {t(`productUsage.states.${data.status}`)} +

+

{t(`productUsage.stateDetails.${data.status}`)}

+ {data.installation_id && ( + + )} + {data.last_report_date && ( +

{t('productUsage.lastReport', { date: data.last_report_date })}

+ )} + {data.last_error && ( +

{t('productUsage.deliveryProblem')}

+ )} +
+ {data.status === 'disabled' ? ( + + ) : ( + <> + + + + )} + + {t('productUsage.transparency')} + +
+
+ {active && ( + <> +
+

+ {t('productUsage.inspect')} +

+
+ + + + +
+ {portalUrl && ( + + {t('productUsage.openPortal')} + + )} + {preview !== null && ( +
+                {JSON.stringify(preview, null, 2)}
+              
+ )} +
+
{ + e.preventDefault(); + void run(async () => { + const result = await service.feedback({ + ...form, + name: named ? form.name : '' + }); + setMessage( + t( + result.delivered + ? 'productUsage.feedbackSent' + : result.queued + ? 'productUsage.queued' + : 'productUsage.failed' + ) + ); + setForm({ + ...form, + title: '', + body: '', + allow_public: false, + allow_marketing: false + }); + }); + }} + > +

+ {t('productUsage.feedbackTitle')} +

+

{t('productUsage.feedbackDisclosure')}

+ + +