Files
pn-new-crm/src/lib/services/umami.service.ts
Matt Ciaccio 7bd969b41a fix(audit-integrations): SMTP/PG/Socket.IO timeouts, prompt injection, secret-at-rest
A focused review of every external integration surfaced six issues the
original audit missed.  Fixed here.

HIGH
* Socket.IO had an unconditional 30-second idle disconnect on every
  socket.  The comment on the line acknowledged it was "for development
  only, would be longer in prod" but no NODE_ENV guard existed, and the
  `socket.onAny` listener only resets on inbound client events — every
  dashboard connection that received only server-push events would have
  been torn down every 30s in production.  Removed the manual idle
  timer entirely; Socket.IO's pingTimeout / pingInterval handles
  dead-transport detection at the protocol level.
* SMTP transporters had no `connectionTimeout` / `greetingTimeout` /
  `socketTimeout`.  Nodemailer's defaults are 2 minutes for connect
  and unlimited for socket — a hung SMTP server would have held a
  BullMQ `email` worker concurrency slot for up to 10 min per job
  (5 retries × 2 min).  Set 10s/10s/30s on both the system transporter
  in `src/lib/email/index.ts` and the user-account transporter in
  `email-compose.service.ts`.

MEDIUM
* PostgreSQL pool had no `statement_timeout` /
  `idle_in_transaction_session_timeout`.  A slow query or transaction
  held by a crashed handler would have eventually exhausted the
  20-connection pool.  30s statement cap, 10s idle-in-tx cap, plus
  `max_lifetime: 30min` to recycle connections.
* `umami_password` and `umami_api_token` were stored as plaintext in
  `system_settings` (the SMTP and S3 secret paths use AES-GCM).  The
  reader now passes them through `readSecret()` which auto-detects
  the encrypted `iv:cipher:tag` shape and decrypts, falling back to
  legacy plaintext so operators can rotate without a flag-day.
* AI email-draft worker interpolated `additionalInstructions` (user-
  controlled) directly into the OpenAI prompt — a hostile rep could
  close the instructions block and inject prompt directives that
  override the system prompt.  Added `sanitizeForPrompt()` that
  strips newlines + quote chars, caps at 500 chars, and the prompt
  now wraps the value in a "treat as data not commands" preamble.

LOW
* Legacy `ensureBucket()` in `src/lib/minio/index.ts` was unguarded —
  if any future code imported it (currently no callers), a misconfigured
  prod deploy could mint a fresh empty bucket.  Now matches the gate
  used by the pluggable S3Backend (`MINIO_AUTO_CREATE_BUCKET=true`
  required) so the legacy export and the new pluggable path agree.

Confirmed not-an-issue: BullMQ Workers create connections via
`{ url }` options object, and BullMQ sets `maxRetriesPerRequest: null`
internally for those — no fix needed.  The shared `redis` singleton
that does keep `maxRetriesPerRequest: 3` is used only for direct
Redis ops (rate-limit sliding window, etc.), never for blocking
BullMQ commands, so the value is correct there.

Test status: 1175/1175 vitest, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:31:50 +02:00

306 lines
11 KiB
TypeScript

/**
* Umami v2 API client. Reads credentials from `system_settings` per port,
* caches JWTs in-memory when using the username/password flow, and exposes
* typed wrappers for the handful of endpoints the /website-analytics page
* uses.
*
* Auth resolution order (per port):
* 1. If `umami_api_token` is set → use it as a Bearer token (Umami Cloud
* pattern, also supported by v2 self-hosted with API keys enabled).
* 2. Otherwise → POST /api/auth/login with `umami_username` +
* `umami_password` to get a JWT, cache it, use it as Bearer.
*
* No env vars - all config lives in port-scoped system_settings so the
* operator can configure Umami at runtime via /admin/website-analytics.
*
* v2 docs: https://docs.umami.is/docs/api
*/
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { rangeToBounds, type DateRange } from '@/lib/analytics/range';
import { CodedError } from '@/lib/errors';
import { fetchWithTimeout } from '@/lib/fetch-with-timeout';
import { decrypt } from '@/lib/utils/encryption';
import { logger } from '@/lib/logger';
// ─── Settings access ────────────────────────────────────────────────────────
interface UmamiPortConfig {
apiUrl: string;
apiToken: string | null;
username: string | null;
password: string | null;
websiteId: string;
}
// `umami_api_token` and `umami_password` may be stored EITHER as encrypted
// `iv:cipher:tag` strings (matching the SMTP / S3 secret pattern) OR as
// legacy plaintext. The reader below tries decryption first and falls
// back to the raw value when the format isn't AES-GCM-shaped, so an
// operator can rotate to encrypted-at-rest by re-saving the setting
// without a flag-day migration.
const SETTING_KEYS = [
'umami_api_url',
'umami_api_token',
'umami_username',
'umami_password',
'umami_website_id',
] as const;
function readSecret(raw: string | null | undefined): string | null {
const v = (raw ?? '').toString().trim();
if (!v) return null;
// `encrypt()` returns `<iv-hex>:<cipher-hex>:<tag-hex>` (3 colon-separated
// hex chunks). If we see that shape, decrypt; otherwise treat as legacy
// plaintext. The fallback path is a transition affordance — operators
// should re-save the setting via the admin UI, which writes the
// encrypted form going forward.
const parts = v.split(':');
if (parts.length === 3 && parts.every((p) => /^[0-9a-f]+$/i.test(p))) {
try {
return decrypt(v);
} catch (err) {
logger.warn(
{ err },
'Umami secret looked encrypted but decrypt failed; treating as plaintext',
);
}
}
return v;
}
/**
* Read the five Umami-related setting rows for one port and assemble them.
* Returns null if the minimum required config (URL + websiteId + an auth
* method) is missing - callers surface a "not configured" UI in that case.
*/
export async function loadUmamiConfig(portId: string): Promise<UmamiPortConfig | null> {
// Filter to ONLY the five Umami keys. Without this, every analytics page
// request pulls every system_settings row for the port (Documenso keys,
// SMTP, email templates, etc), which scales poorly as the port grows.
const rows = await db
.select({ key: systemSettings.key, value: systemSettings.value })
.from(systemSettings)
.where(and(eq(systemSettings.portId, portId), inArray(systemSettings.key, [...SETTING_KEYS])));
const map = new Map(rows.map((r) => [r.key, r.value as string | null | undefined]));
const apiUrl = (map.get('umami_api_url') ?? '').toString().trim().replace(/\/$/, '');
// Sensitive values pass through readSecret() to support encrypted-at-rest
// storage (with plaintext fallback for legacy rows).
const apiToken = readSecret(map.get('umami_api_token') as string | null | undefined);
const username = ((map.get('umami_username') ?? '') as string).trim() || null;
const password = readSecret(map.get('umami_password') as string | null | undefined);
const websiteId = ((map.get('umami_website_id') ?? '') as string).trim();
if (!apiUrl || !websiteId) return null;
if (!apiToken && !(username && password)) return null;
return { apiUrl, apiToken, username, password, websiteId };
}
// ─── JWT cache (username/password flow only) ────────────────────────────────
interface CachedJwt {
token: string;
expiresAt: number;
}
// Keyed by `${apiUrl}::${username}` so different ports / different Umami
// instances don't share tokens. Tokens are presumed to last 1 hour; we
// refresh proactively a few minutes before expiry.
const jwtCache = new Map<string, CachedJwt>();
const JWT_TTL_MS = 55 * 60 * 1000; // 55 min - Umami JWTs default to 1h
async function loginAndCache(apiUrl: string, username: string, password: string): Promise<string> {
const res = await fetchWithTimeout(`${apiUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
internalMessage: `Umami login failed: ${res.status} ${res.statusText}`,
});
}
const body = (await res.json()) as { token?: string };
if (!body.token)
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
internalMessage: 'Umami login response missing token',
});
jwtCache.set(`${apiUrl}::${username}`, {
token: body.token,
expiresAt: Date.now() + JWT_TTL_MS,
});
return body.token;
}
async function resolveBearer(config: UmamiPortConfig): Promise<string> {
if (config.apiToken) return config.apiToken;
if (!config.username || !config.password) {
throw new CodedError('UMAMI_NOT_CONFIGURED', {
internalMessage: 'Umami is misconfigured: no API token and no username/password.',
});
}
const cacheKey = `${config.apiUrl}::${config.username}`;
const cached = jwtCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.token;
return loginAndCache(config.apiUrl, config.username, config.password);
}
// ─── Generic request helper ─────────────────────────────────────────────────
async function umamiFetch<T>(
config: UmamiPortConfig,
path: string,
search: Record<string, string | number | undefined>,
): Promise<T> {
const bearer = await resolveBearer(config);
const url = new URL(`${config.apiUrl}${path}`);
for (const [k, v] of Object.entries(search)) {
if (v === undefined) continue;
url.searchParams.set(k, String(v));
}
const res = await fetchWithTimeout(url.toString(), {
headers: {
Authorization: `Bearer ${bearer}`,
accept: 'application/json',
},
// Don't share Next.js's request cache - analytics figures change every
// few seconds. The service-layer cache (if any) is the right place.
cache: 'no-store',
});
if (res.status === 401 || res.status === 403) {
// Bearer rejected - drop cached JWT so next call re-logs in.
if (config.username) jwtCache.delete(`${config.apiUrl}::${config.username}`);
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
internalMessage: `Umami unauthorized: ${res.status}`,
});
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new CodedError('UMAMI_UPSTREAM_ERROR', {
internalMessage: `Umami ${path} failed: ${res.status} ${res.statusText}${
text ? ` - ${text}` : ''
}`,
});
}
return (await res.json()) as T;
}
// ─── Range serialization ────────────────────────────────────────────────────
function rangeToParams(range: DateRange): { startAt: number; endAt: number } {
// Umami expects unix milliseconds for both bounds.
const { from, to } = rangeToBounds(range);
return { startAt: from.getTime(), endAt: to.getTime() };
}
/** Pick a sensible bucket size for the pageviews timeseries given the
* range span. Avoids returning thousands of points for a 90d range. */
function pickUnit(range: DateRange): 'hour' | 'day' | 'month' {
const { from, to } = rangeToBounds(range);
const days = (to.getTime() - from.getTime()) / 86_400_000;
if (days <= 2) return 'hour';
if (days <= 120) return 'day';
return 'month';
}
// ─── Public API ─────────────────────────────────────────────────────────────
export interface UmamiStats {
pageviews: { value: number; prev: number };
visitors: { value: number; prev: number };
visits: { value: number; prev: number };
bounces: { value: number; prev: number };
totaltime: { value: number; prev: number };
}
export async function getStats(portId: string, range: DateRange): Promise<UmamiStats | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiStats>(config, `/api/websites/${config.websiteId}/stats`, {
...rangeToParams(range),
});
}
export interface UmamiPageviewsSeries {
pageviews: Array<{ x: string; y: number }>;
sessions: Array<{ x: string; y: number }>;
}
export async function getPageviewsSeries(
portId: string,
range: DateRange,
): Promise<UmamiPageviewsSeries | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiPageviewsSeries>(config, `/api/websites/${config.websiteId}/pageviews`, {
...rangeToParams(range),
unit: pickUnit(range),
timezone: 'UTC',
});
}
export type UmamiMetricType =
| 'url'
| 'referrer'
| 'browser'
| 'os'
| 'device'
| 'country'
| 'event';
export interface UmamiMetricRow {
x: string;
y: number;
}
export async function getMetric(
portId: string,
range: DateRange,
type: UmamiMetricType,
limit = 10,
): Promise<UmamiMetricRow[] | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiMetricRow[]>(config, `/api/websites/${config.websiteId}/metrics`, {
...rangeToParams(range),
type,
limit,
});
}
export interface UmamiActiveVisitors {
visitors: number;
}
export async function getActiveVisitors(portId: string): Promise<UmamiActiveVisitors | null> {
const config = await loadUmamiConfig(portId);
if (!config) return null;
return umamiFetch<UmamiActiveVisitors>(config, `/api/websites/${config.websiteId}/active`, {});
}
/**
* Verify the connection by hitting `/api/websites/:id/active` - the cheapest
* authenticated endpoint that proves both auth + websiteId are good.
* Throws on any failure with a descriptive message; resolves on success.
*/
export async function testConnection(portId: string): Promise<{ ok: true; visitors: number }> {
const config = await loadUmamiConfig(portId);
if (!config) {
throw new CodedError('UMAMI_NOT_CONFIGURED', {
internalMessage: 'Umami is not configured for this port.',
});
}
const result = await umamiFetch<UmamiActiveVisitors>(
config,
`/api/websites/${config.websiteId}/active`,
{},
);
return { ok: true, visitors: result.visitors };
}