1095 lines
43 KiB
JavaScript
1095 lines
43 KiB
JavaScript
'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 };
|