'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, LEGACY_FEATURE_KEYS, CATALOG, CURRENT_SCHEMA_VERSION, CONSENT_VERSIONS, schemaForConsent, schemaRank, featureKeysFor, observesUse, LAYOUTS } = require('./protocol.cjs'); // The collector this installation reports to. Declared once rather than // inline, because it is a deployment choice: self-hosters point // USAGE_COLLECTOR_URL at their own collector, and the admin UI links to // whatever is configured rather than to this default. Kept out of the wire // contract — `schema.cjs` is vendored byte-identical with picpeak-usage and // its `$id` is a schema identity, not a delivery address. const DEFAULT_COLLECTOR_URL = 'https://usage.picpeak.app'; 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', 'database_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'; // `events.color_theme` holds either a theme object or the NAME of a preset — // the admin theme picker stores names, and eventTypeService seeds them too // (`theme_preset: 'corporateTimeline'`). Reading only `value.galleryLayout` // therefore reported `grid` for every preset-themed install. // // Only the layout each name maps to is duplicated here, not the presets // themselves; frontend/src/types/theme.types.ts stays the source of truth. A // name this map does not know reports `other` rather than a confident `grid`, // so a preset added on the frontend degrades to "something else" instead of // quietly inflating the grid count. const PRESET_LAYOUTS = { default: 'grid', elegantWedding: 'grid', modernMasonry: 'masonry', birthdayFun: 'carousel', corporateTimeline: 'timeline', artisticMosaic: 'mosaic', darkClassic: 'grid', darkElegant: 'grid', darkModern: 'masonry', galleryPremium: 'gallery-premium', galleryStory: 'gallery-story' }; function resolveLayout(value) { const named = typeof value === 'string' ? PRESET_LAYOUTS[value] : value && typeof value === 'object' ? value.galleryLayout : null; if (!named) return typeof value === 'string' ? 'other' : 'grid'; return LAYOUTS.includes(named) ? named : 'other'; } const parse = (value) => { try { return JSON.parse(value); } catch (_) { return value; } }; class UsageService { // The collector has provably never accepted anything from this identity: // no packet was ever acknowledged, so there is nothing remote to delete. // Abandoning such a participation is harmless, which is why it may be // offered without the warning the registered case needs. static neverAccepted(state) { return Number(state?.sequence || 0) === 0 && !state?.last_receipt; } // The two ways a participation can reach a state no amount of retrying will // resolve. Kept as one predicate so the settings page and the endpoint can // never disagree about whether the exit is available. static abandonable(state) { if (!['activation_pending', 'deletion_pending'].includes(state?.status)) return false; // The signing key is gone: the delete packet can never be produced. if (state.last_error === 'SIGNING_KEY_UNREADABLE') return true; // Or the collector never accepted anything and a delivery is failing — // the ordinary shape of "opted in against a collector that does not speak // this report version yet". Nothing is registered remotely, so clearing // the local state costs nothing and is the only way out of the tab. return Boolean(state.last_error) && UsageService.neverAccepted(state); } schemaVersion(state) { return schemaForConsent(state?.consent_version) || 'usage.v1'; } 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; // Falls back to the default whenever nothing usable is configured — // unset, empty, or whitespace. Deployments that template the variable in // (docker-compose writes USAGE_COLLECTOR_URL=${USAGE_COLLECTOR_URL:-...}) // can hand over an empty string, and that must mean "use the default", // not "no collector". A value that is present but malformed is NOT // silently replaced: quietly retargeting a self-hoster's collector at // ours would send their reports somewhere they did not choose. const configured = (options.endpoint ?? process.env.USAGE_COLLECTOR_URL ?? '').trim(); this.endpoint = configured || DEFAULT_COLLECTOR_URL; 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) { try { 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' ); } catch (error) { // The signing key is encrypted with USAGE_ENCRYPTION_KEY, which defaults // to JWT_SECRET — so rotating JWT_SECRET, the correct response to a // suspected compromise, makes this key unreadable. Without a name of its // own that surfaced as a generic DELIVERY_FAILED that retried forever, // and it silently blocks the DELETE packet too: participation could // never be withdrawn from the collector. Tagged so the operator is told // what actually happened. const failure = new Error('Usage signing key cannot be decrypted'); failure.code = 'SIGNING_KEY_UNREADABLE'; throw failure; } } 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(); // Reported, not thrown. status() used to call collectorUrl() bare, so a // misconfigured USAGE_COLLECTOR_URL — a bare hostname, a path, a query, or // http in production — failed this request outright. The settings page // renders one generic failure when its status query errors, so the // operator saw "the operation could not be completed" with no cause AND // no way to reach their own state: they could not read the status or even // withdraw, because every control on that tab is behind this call. let collectorUrl = null; let collectorError = null; try { collectorUrl = this.collectorUrl(); } catch { collectorError = 'INVALID_COLLECTOR_URL'; } return { status: state.status, notice_dismissed: Boolean(state.notice_dismissed), installation_id: state.installation_id, collector_url: collectorUrl, collector_error: collectorError, schema_version: this.schemaVersion(state), available_schema_version: CURRENT_SCHEMA_VERSION, consent_version: state.consent_version || 'usage-consent.v1', consent_update_available: state.status === 'active' && this.schemaVersion(state) !== CURRENT_SCHEMA_VERSION, last_report_date: state.last_report_date, last_error: state.last_error, // Epoch ms, or null when nothing is being paced. The settings page shows // it so a waiting install reads as "waiting" rather than as broken. retry_after: Number(state.next_attempt_at || 0) > this.now() ? Number(state.next_attempt_at) : null, // Whether the only remaining exit should be offered. Without it the tab // shows a permanent error and no control that can clear it. can_abandon: UsageService.abandonable(state), // Distinguishes the harmless case (nothing was ever registered, so // abandoning deletes nothing remote) from the one that needs a warning. abandon_never_registered: UsageService.neverAccepted(state), pending_action: state.pending_packet ? JSON.parse(state.pending_packet).action : null, last_packet: state.last_packet ? JSON.parse(state.last_packet) : null, privacy_receipts: state.privacy_receipts ? JSON.parse(state.privacy_receipts) : {}, 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 }); } } // Consecutive failures pace the unattended sender: 2, 4, 8, 16, 32 minutes, // then hourly. Capped rather than unbounded because a collector that comes // back after a long outage should be noticed within the hour, and a report // is only due once per UTC day anyway. backoffMs(attempts) { return Math.min(2 ** Math.max(1, attempts), 60) * 60000; } async noteDeliveryFailure() { const state = await this.state(); const attempts = Number(state?.attempts || 0) + 1; await this.db('product_usage_state') .where({ id: 1 }) .update({ attempts, next_attempt_at: this.now() + this.backoffMs(attempts) }); } async clearDeliveryBackoff() { await this.db('product_usage_state') .where({ id: 1 }) .update({ attempts: 0, next_attempt_at: 0 }); } async dismiss() { await this.db('product_usage_state') .where({ id: 1 }) .update({ notice_dismissed: formatBoolean(true) }); return this.status(); } async enable(consent) { if (!Object.values(CONSENT_VERSIONS).includes(consent)) throw new ValidationError('Explicit usage consent is required'); // Read BEFORE the lease, deliberately. locked() claims the lease and then // reads the row in a second statement; a /disable completing between // those two would be adopted as this activation's own baseline and // silently absorbed. Taking the baseline first inverts that: every // increment from this point on — including one in that gap — is later // than the value the claim tests for, so the claim fails and the // withdrawal wins. An increment from BEFORE this read is a withdrawal the // operator already completed, and a deliberate opt-in afterwards should // not be vetoed by it. const cancelSeq = Number((await this.state())?.cancel_seq || 0); 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 }, this.schemaVersion({ consent_version: consent })); // Identity generation and the binding file are the slow part, and the // row still reads `disabled` throughout — which is why /disable could // not see an activation in flight and its conditional update matched // nothing. const instanceBinding = await this.binding(true); // The claim itself carries the check. Re-reading the flag and then // updating would only move the window rather than close it: this is one // conditional UPDATE, so a /disable that lands first makes it match no // rows and the registration is never sent. const claimed = await this.db('product_usage_state') .where({ id: 1, status: 'disabled', cancel_seq: cancelSeq }) .update({ status: 'activation_pending', consent_version: consent, notice_dismissed: formatBoolean(true), installation_id: identity.installation_id, public_key: identity.public_key, private_key_encrypted: this.encrypt(identity.private_key), instance_binding: instanceBinding, sequence: 0, pending_packet: JSON.stringify(pending), last_error: null, attempts: 0, next_attempt_at: 0 }); // Withdrawn while activating. Participation stays off and nothing was // registered, so there is nothing to delete remotely either. if (!claimed) return; await this.deliver(await this.state()); }); return this.status(); } async disable() { // Counted unconditionally and first, because the interesting case is the // one where there is seemingly nothing to stop: while /enable is still // generating an identity the row reads `disabled`, so the conditional // update below matches nothing and the lease conflict from tick() is // swallowed — the admin was told participation was off while the // activation went on to complete. enable() claims its state only if this // counter is unchanged, so a withdrawal landing in that window wins. await this.db('product_usage_state') .where({ id: 1 }) .increment('cancel_seq', 1); // 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, attempts: 0, next_attempt_at: 0 }); await this.db('product_usage_markers').delete(); try { await this.tick({ force: true }); } 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(); } // The escape hatch for a withdrawal that can never be signed. When // USAGE_ENCRYPTION_KEY — or the JWT_SECRET it falls back to — has been // rotated, the private key is unreadable, so the delete packet cannot be // produced at all. Retrying and disabling both no-op forever, and enable() // refuses because the row is not `disabled`: the feature is bricked with no // control left. Restoring the old key material is the correct fix and stays // the documented one, but an operator who rotated because of a suspected // compromise no longer has it. // // This drops the local identity and says so honestly: collection is already // stopped, but the collector was never told, so the receipt records // `collector-unconfirmed` rather than claiming a deletion that did not // happen. Deliberately not folded into enable() — abandoning an // unconfirmed deletion is its own decision, not a side effect of opting in. async abandon() { await this.locked(async (state) => { if (!UsageService.abandonable(state)) throw new ConflictError( 'Only a participation that cannot be completed can be abandoned' ); const neverRegistered = UsageService.neverAccepted(state); await fs.unlink(this.bindingPath).catch((error) => { if (error.code !== 'ENOENT') throw error; }); await this.db('product_usage_markers').delete(); const receipts = state.privacy_receipts ? JSON.parse(state.privacy_receipts) : {}; await this.db('product_usage_state') .where({ id: 1 }) .whereIn('status', ['activation_pending', 'deletion_pending']) .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, attempts: 0, next_attempt_at: 0, feedback_preferences: null, privacy_receipts: JSON.stringify({ ...receipts, last_abandonment: { receipt_version: 'local-audit.v1', kind: 'abandonment', receipt_id: crypto.randomUUID(), confirmed_at: new Date(this.now()).toISOString(), // Two different truths, and the receipt has to tell them apart. // Nothing was ever accepted -> there is provably nothing at the // collector. Otherwise the collector may still hold reports and // was never told to delete them; saying "unconfirmed" is the // only honest wording for that. status: neverRegistered ? 'never-registered' : 'collector-unconfirmed', reason: state.last_error, installation_id: state.installation_id, scope: ['local identity', 'local markers', 'local key material'] } }) }); }); 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) { // A lost receipt may mean this packet is already stored by the collector. // Preserve its original schema, date and payload across binary upgrades; // only re-sign transport metadata, never rebuild under the same packet ID. const packet = JSON.parse(state.pending_packet); if ( packet.action !== 'delete' && (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 }) // Never over an opt-out. A withdrawal that arrived while this // binding lookup was in flight would otherwise be replaced by // identity_conflict, and tick() stops there — so the deletion the // operator asked for would never be sent. The collector-conflict // handler below already guards the same way. .whereNot({ status: 'deletion_pending' }) .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()) ); // 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 // had its registration or report dispatched afterwards. This is not // about an already-in-flight request; it is about not starting one. if ( packet.action !== 'delete' && (await this.state()).status === 'deletion_pending' ) return null; 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, privacy_receipts: JSON.stringify({ last_deletion: { receipt_version: 'local-audit.v1', kind: 'deletion', receipt_id: crypto.randomUUID(), confirmed_at: new Date(this.now()).toISOString(), status: 'collector-confirmed', scope: [ 'reports', 'snapshots', 'feedback', 'votes', 'sessions', 'operations', 'registration' ] } }), last_report_date: null, last_error: null, sequence: 0, attempts: 0, next_attempt_at: 0, feedback_preferences: null }); } else { const storedReceipt = { ...receipt }; delete storedReceipt.session_token; const update = { sequence: packet.sequence, pending_packet: null, last_error: null, attempts: 0, next_attempt_at: 0, last_receipt: JSON.stringify(storedReceipt) }; if (packet.action === 'report') { update.last_packet = JSON.stringify(envelope); update.last_report_date = packet.payload.report_date; } // Activation goes in with the acknowledgement, not after it. Split // across two writes, a failure or a stop between them left the row // `activation_pending` with pending_packet already cleared — and // tick() has nothing to retry from there, so the installation was // registered with the collector but permanently stuck locally. // Still guarded on activation_pending, so a withdrawal that arrived // first is not overwritten. const ack = this.db('product_usage_state').where({ id: 1 }); if (packet.action === 'register') { update.status = 'active'; // Precisely activation_pending, not merely "not withdrawing" — // this write is the one that turns participation on. ack.where({ status: 'activation_pending' }); } else { ack.whereNot({ status: 'deletion_pending' }); } if (packet.action === 'consent') { // Upgrade and reset the observation period atomically. A late receipt // must never re-enable collection after an intervening opt-out. await this.db.transaction(async (tx) => { const upgraded = await tx('product_usage_state') .where({ id: 1, status: 'active', installation_id: packet.installation_id }) .update({ ...update, consent_version: packet.payload.consent_version }); if (upgraded) await tx('product_usage_markers').delete(); }); } else { await ack.update(update); } await this.db('product_usage_state') .where({ id: 1, status: 'deletion_pending' }) .update({ sequence: packet.sequence, pending_packet: null }); } 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', attempts: 0, next_attempt_at: 0 }); return null; } const conflict = [ 'SEQUENCE_CONFLICT', 'IDENTITY_CONFLICT', 'IDENTITY_REVOKED', 'NOT_REGISTERED', 'PACKET_CONFLICT' ].includes(error.code); // A collector that answers INVALID_PACKET to a registration or a deletion // does not understand the wire version we speak — most often because it // has not been upgraded to usage.v2 yet. Retrying cannot fix that, and // reporting it as DELIVERY_FAILED sent the operator looking for a // network problem they do not have. const code = conflict ? error.code : error.code === 'SIGNING_KEY_UNREADABLE' ? 'SIGNING_KEY_UNREADABLE' : rejected && ['register', 'delete'].includes(packet.action) ? 'SCHEMA_NOT_ACCEPTED' : 'DELIVERY_FAILED'; await this.db('product_usage_state') .where({ id: 1 }) .update({ last_error: code }); // Paced, not abandoned: the packet stays pending and the operator can // still force a retry from the settings page. Only the automatic sender // waits, which is what stops one permanently rejected packet from // producing one collector request per admin click. await this.noteDeliveryFailure(); if (conflict && packet.action !== 'delete') { await this.db('product_usage_state') .where({ id: 1 }) .whereNot({ status: 'deletion_pending' }) .update({ status: 'identity_conflict' }); } return null; } } // `force` is what the Retry button and /disable pass: an operator asking for // an attempt now must not be held behind a backoff they can see and want to // skip. The unattended callers — /activity and the settings ticker — leave // it off, so a failing packet costs one request per backoff window instead // of one per admin action. async tick({ force = false } = {}) { await this.locked(async (state) => { if (state.status === 'disabled') return; if (!force && Number(state.next_attempt_at || 0) > this.now()) return; if (state.status === 'deletion_pending') { const packet = makePacket(state, 'delete', Number(state.sequence), {}, this.schemaVersion(state)); 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(this.schemaVersion(state)); const packet = makePacket( state, 'report', Number(state.sequence) + 1, payload, this.schemaVersion(state) ); state.pending_packet = JSON.stringify(packet); // Only while still active. /disable clears pending_packet and moves the // status without taking the lease, so an unconditional write here could // put a report back into the outbox after the withdrawal had emptied // it — and deliver() would then leave it there, since it declines to // send anything but the delete. const enqueued = await this.db('product_usage_state') .where({ id: 1, status: 'active' }) .update({ pending_packet: state.pending_packet }); if (!enqueued) return; await this.deliver(state); }); return this.status(); } async markUsed(features, { destinationBackup = false, legacyFeatures } = {}) { let allowed = [...new Set([...features, ...(legacyFeatures || [])])].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; const version = this.schemaVersion(state); if (legacyFeatures) allowed = version === 'usage.v1' ? legacyFeatures : features; allowed = allowed.filter((feature) => featureKeysFor(version).includes(feature) && observesUse(feature, version)); if (!allowed.length) return; // Only when the operation actually writes to the configured backup // destination. Deriving this from "a backup ran while S3 is configured" // marked S3 as USED for a local database backup or a .picpeak export, // which the middleware also counts as `backup` — so merely configuring // S3 and downloading a local export claimed S3 was in use. if (destinationBackup && 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'); if (version !== 'usage.v1') allowed.push('s3_backups'); } } await tx('product_usage_markers') .insert([...new Set(allowed)].map((feature) => ({ feature }))) .onConflict('feature') .ignore(); }); } // `persist` is false for the settings preview. snapshot() records applied // custom CSS as a lifetime marker, which meant the "see exactly what would // be sent" view changed what gets sent — a read with a write behind it, in // the one place whose whole job is transparency. The reported value is // unaffected: the marker is derived here either way, and the next real // report persists it. async snapshot(version, { persist = true } = {}) { version = version || this.schemaVersion(await this.state()); 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', version !== 'usage.v1' ? [...new Set([...Object.values(FLAG_MAP), 'incomingMail', ...Object.values(CATALOG.features).map((f) => f.flag).filter(Boolean)])] : 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( LEGACY_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); // Either kind counts. The middleware records /backup/* and // /database-backup/* under the same capability, so reading only // backup_enabled reported `used: true, configured: false` for an install // whose only backup is the scheduled database one. features.backup.configured = truth(settings.backup_enabled) || truth(settings.database_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 || {}; // An enabled template applied to an event is gallery styling by the same // definition as the settings fields — the Custom CSS tab is where both are // authored. Existence only; template contents are never read. const appliedTemplate = await this.db('css_templates') .where('is_enabled', formatBoolean(true)) .whereIn( 'id', this.db('events').whereNotNull('css_template_id').select('css_template_id') ) .select('id') .first(); features.custom_css.configured = Boolean( settings.general_custom_css || settings.general_public_site_custom_css || theme.customCss || appliedTemplate ); // 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(); // An event with no theme of its own renders with the global one. const inheritedLayout = resolveLayout(theme); for (const row of themes) { const value = parse(row.color_theme); if (row.color_theme === null || row.color_theme === '') { layouts.add(inheritedLayout); } else { layouts.add(resolveLayout(value)); } 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) { if (persist) await this.markUsed(['custom_css']); features.custom_css.used = true; } const now = new Date(this.now()).toISOString(); const expanded = version !== 'usage.v1' ? await require('./expandedSnapshot').expandSnapshot(this.db, { features, flags, used, now: this.now(), version }) : features; return { picpeak_version: this.version, report_date: now.slice(0, 10), generated_at: now, features: expanded, gallery_layouts: [...layouts].sort(), ...(['usage.v3', 'usage.v4'].includes(version) ? { inventory: await require('./inventorySnapshot').inventorySnapshot(this.db) } : {}) }; } async preview() { const state = await this.state(); if (state.status !== 'active') throw new ConflictError('Usage participation is not active'); return this.snapshot(null, { persist: false }); } 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', 'consent'].includes(action)) throw new ValidationError('Invalid usage action'); const targetVersion = action === 'consent' ? schemaForConsent(payload?.consent_version) : null; if (action === 'consent' && (!targetVersion || targetVersion === 'usage.v1')) throw new ValidationError('Explicit usage consent is required'); if (action === 'consent' && schemaRank(targetVersion) <= schemaRank(this.schemaVersion(state))) throw new ConflictError('Usage consent is already current'); const packet = makePacket( state, action, Number(state.sequence) + 1, payload, action === 'consent' ? targetVersion : this.schemaVersion(state) ); // 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); // Same guard as the report enqueue in tick(): a command captured while // active must not restore its payload — feedback body and name // included — into the outbox that /disable has just cleared. const enqueued = await this.db('product_usage_state') .where({ id: 1, status: 'active' }) .update({ pending_packet: state.pending_packet }); if (!enqueued) throw new ConflictError('Usage participation is not active'); 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. const result = await this.post( '/api/participant/lookup', { installation_id: state.installation_id }, Infinity ); // Only the last export during this participation is retained locally. // Do not restore audit state if opt-out/identity replacement happened while // the export was in flight. The downloaded file carries the full receipt. const receipts = state.privacy_receipts ? JSON.parse(state.privacy_receipts) : {}; await this.db('product_usage_state') .where({ id: 1, status: 'active', installation_id: state.installation_id }) .update({ privacy_receipts: JSON.stringify({ ...receipts, last_export: { receipt_version: 'local-audit.v1', kind: 'export', receipt_id: crypto.randomUUID(), confirmed_at: new Date(this.now()).toISOString(), // Reports only. Counting every packet — feedback, votes, portal // sessions, the registration — and labelling the total "usage // reports" made a privacy receipt state something untrue about // its own contents, which is exactly the document that has to be // exact. `packet_count` keeps the total available alongside it. report_count: Array.isArray(result.packets) ? result.packets.filter( (envelope) => envelope?.packet?.action === 'report' ).length : 0, packet_count: Array.isArray(result.packets) ? result.packets.length : 0, scope: [ 'accepted usage reports', 'accepted participant operations' ] } }) }); return result; } } module.exports = { UsageService, FLAG_MAP };