fix(maps): resolve the Places key per instance, not per whoever is asking

Searching worked for the admin and 403'd for every other member. getMapsKey read
the calling user's own row first and then fell through to "any admin" via LIMIT 1
with no ORDER BY, so the admin who pasted the key hit their own row while everyone
else got a non-deterministic stranger's. That fallback is what CLAUDE.md forbids.

The Places and Unsplash keys bill the install, not the account that pasted them
in, so they become instance configuration: an encrypted app_settings row, resolved
as env -> instance -> the caller's own row, the same shape #1772 chose for the LLM
endpoint. The per-user column stays as the last step because /me/api-keys has
always accepted a personal key.

The trailing migration carries an existing install's key into the instance row so
nothing degrades to OpenStreetMap on upgrade, but only where that key already was
the whole install's: as soon as a second row holds one, it is left alone, because
the instance row outranks everybody's own column and an upgrade must not move a
member onto someone else's key and bill.

Two things from the same report:

The key test button probed with field mask places.displayName while the search
asks for places.rating and more, which is a separate SKU and can be restricted on
its own, so the button could pass while every search failed. It now probes the
resolved key with the search's own mask.

Saving API keys wrote no audit line. It now writes one settings.api_keys_update
row naming which fields changed, never their values, comparing cleartext because
the random IV makes every ciphertext look different.

Closes #1939
This commit is contained in:
Maurice
2026-08-17 14:20:45 +02:00
committed by Maurice
parent c9124c9b63
commit 1a0834b866
20 changed files with 1171 additions and 110 deletions
+46
View File
@@ -3909,6 +3909,52 @@ function runMigrations(db: Database.Database): void {
)
`);
},
// #1939 — the Google Places and Unsplash keys are instance configuration and
// now resolve out of app_settings (nest/settings/instance-api-keys.ts). This
// carries the value the old resolver would have handed out — the lowest-id
// admin who has one — into that row, so an install that upgrades keeps the
// key it was searching with instead of falling back to OpenStreetMap.
// Only where that key already was the whole install's, though. The resolver
// reads the instance row before the caller's own column, so promoting a
// personal key while a second row still holds one would move that member
// onto a stranger's key and a stranger's bill without anyone saying so. As
// soon as another row has a value for the same column nothing is written:
// everybody keeps resolving what they resolved before, and a member with no
// key of their own searches via OpenStreetMap until an admin saves the
// instance key once in the panel. Handing a key to the whole instance is the
// admin's call, not a migration's.
// The users columns are left alone: they are still the per-user fallback,
// and nothing here can lose a value. Stored blobs are copied verbatim; both
// sides use the same apiKeyCrypto format, legacy plaintext included.
// Appended LAST — the array is index-addressed against schema_version.
() => {
const upsert = db.prepare(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
);
for (const column of ['maps_api_key', 'unsplash_api_key'] as const) {
const existing = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(column) as
| { value: string | null }
| undefined;
if (existing?.value) continue;
const row = db
.prepare(
`SELECT id, ${column} AS value FROM users
WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != ''
ORDER BY id ASC LIMIT 1`
)
.get() as { id: number; value: string } | undefined;
if (!row?.value) continue;
const otherHolder = db
.prepare(
`SELECT 1 FROM users
WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1`
)
.get(row.id);
if (otherHolder) continue;
upsert.run(column, row.value);
}
},
];
if (currentVersion < migrations.length) {
+26
View File
@@ -27,6 +27,19 @@ function resetDemoUser(): void {
console.error('[Demo Reset] Failed to read admin data:', e instanceof Error ? e.message : e);
}
// The Places/Unsplash keys the searches actually use live in app_settings
// since #1939, so they have to survive the restore alongside the columns —
// otherwise the demo instance loses map search on every reset.
interface InstanceKeyRow { key: string; value: string | null }
let instanceKeys: InstanceKeyRow[] = [];
try {
instanceKeys = db.prepare(
"SELECT key, value FROM app_settings WHERE key IN ('maps_api_key', 'unsplash_api_key')"
).all() as InstanceKeyRow[];
} catch (e: unknown) {
console.error('[Demo Reset] Failed to read instance API keys:', e instanceof Error ? e.message : e);
}
// Flush WAL to main DB file
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
@@ -67,6 +80,19 @@ function resetDemoUser(): void {
}
}
if (instanceKeys.length) {
try {
const { db: freshDb } = require('../db/database');
const upsert = freshDb.prepare(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
);
for (const row of instanceKeys) upsert.run(row.key, row.value);
} catch (e: unknown) {
console.error('[Demo Reset] Failed to restore instance API keys:', e instanceof Error ? e.message : e);
}
}
console.log('[Demo Reset] Database restored from baseline');
}
+7
View File
@@ -11,6 +11,7 @@ const ACTION_LABELS: Record<string, string> = {
'user.mfa_enable': 'enabled MFA',
'user.mfa_disable': 'disabled MFA',
'settings.app_update': 'updated settings',
'settings.api_keys_update': 'updated API keys',
'trip.create': 'created trip',
'trip.delete': 'deleted trip',
'admin.user_role_change': 'changed user role',
@@ -38,6 +39,12 @@ function buildInfoSummary(action: string, details?: Record<string, unknown>): st
if (details.require_mfa !== undefined) parts.push(`mfa=${details.require_mfa}`);
return parts.length ? ` (${parts.join(', ')})` : '';
}
if (action === 'settings.api_keys_update') {
// The names, read straight out of details — the writer puts nothing else in
// there, and a key value must never reach a log line.
const changed = Array.isArray(details.changed) ? details.changed : [];
return changed.length ? ` (${changed.join(', ')})` : '';
}
if (action === 'immich.private_ip_configured') {
return details.resolved_ip ? ` url=${details.immich_url} ip=${details.resolved_ip}` : '';
}
+34 -5
View File
@@ -123,22 +123,51 @@ export class AuthController {
return { success: true };
}
/**
* One row per save that actually changed a key, on all three routes that can
* write these columns (#1939 asked for it: the API keys were the only admin
* setting whose change left no trace, while `settings.app_update` next door
* has always been logged).
*
* Names only. `details` is rendered as raw JSON in the admin audit panel and
* mirrored into the debug log, so a value here would leak the key twice over.
* Nothing is written when nothing changed — the panel saves before every test
* click, and each click would otherwise cost a log line.
*/
private auditApiKeys(userId: number, changed: string[], req: Request): void {
if (!changed.length) return;
this.audit.writeAudit({
userId,
action: 'settings.api_keys_update',
resource: 'api_keys',
ip: getClientIp(req),
details: { changed },
});
}
@Put('me/maps-key')
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto) {
return this.profile.updateMapsKey(user.id, body.maps_api_key);
mapsKey(@CurrentUser() user: User, @Body() body: MapsKeyUpdateDto, @Req() req: Request) {
// changedKeys is for the audit line, not for the client: destructured off so
// the response body stays what it always was.
const { changedKeys = [], ...result } = this.profile.updateMapsKey(user.id, body.maps_api_key);
this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/api-keys')
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto) {
return this.profile.updateApiKeys(user.id, body);
apiKeys(@CurrentUser() user: User, @Body() body: ApiKeysUpdateDto, @Req() req: Request) {
const { changedKeys = [], ...result } = this.profile.updateApiKeys(user.id, body);
this.auditApiKeys(user.id, changedKeys, req);
return result;
}
@Put('me/settings')
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto) {
updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
const result = this.profile.updateSettings(user.id, body);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
this.auditApiKeys(user.id, result.changedKeys ?? [], req);
return { success: result.success, user: result.user };
}
+7 -7
View File
@@ -14,6 +14,7 @@ import { PermissionsService } from '../permissions/permissions.service';
import { validatePassword } from '../common/passwordPolicy';
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey } from '../settings/instance-api-keys';
import { EphemeralTokenService } from './ephemeral-token.service';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
// the whole tools fan-out (and via the domain bridges, the Nest services) into
@@ -235,13 +236,12 @@ export class AuthService {
// One directory deeper than the legacy src/services location — the extra
// '../' keeps resolving to the workspace package.json.
const version: string = readEnv().app.appVersion ?? require('../../../package.json').version;
// The operator's key counts too. getMapsKey prefers it over anything in the
// users table, so asking the table alone would report "no key" on an install
// where every lookup works, and the client would hide the Google features it
// is actually able to use.
const hasGoogleKey =
!!readEnv().maps.placesApiKey ||
!!this.db.get("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1");
// Asked through the same resolver the search itself uses, so the client can
// never show Google features that rest on a key this caller does not get —
// nor hide them from a member who does have one (#1939). Unauthenticated the
// question is only about the instance, which is the first two steps of the
// chain; id 0 matches no row.
const hasGoogleKey = !!resolveApiKey(this.db, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey).key;
const oidcDisplayName = readEnv().oidc.displayName ||
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null;
const oidcConfigured = !!(
+127 -23
View File
@@ -7,6 +7,14 @@ import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyC
import { avatarUrl } from '../common/avatarUrl';
import { EMAIL_REGEX, avatarDir, mask_stored_api_key } from './auth.helpers';
import { splitManagedKeys, MANAGED_LOCKED_PROFILE_KEYS } from '../common/managed';
import {
INSTANCE_API_KEY_NAMES,
readInstanceApiKey,
resolveApiKey,
writeInstanceApiKey,
type InstanceApiKeyName,
} from '../settings/instance-api-keys';
import { SEARCH_TEXT_FIELD_MASK } from '../maps/maps.helpers';
import { User } from '../../types';
/**
@@ -41,33 +49,109 @@ export class UserProfileService {
return readEnv().managed.enabled;
}
/** The three key columns plus the role that decides where a save lands. */
private currentKeys(userId: number) {
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>(
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?',
userId
);
}
/**
* The value a save is measured against, in cleartext.
*
* For an admin and one of the two instance-wide names that is the instance
* value, because that is what the panel shows them and what the search uses;
* their own column only speaks when no instance value has been set yet.
*/
private storedKeyPlaintext(
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key',
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
isAdmin: boolean,
): string {
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
const instance = readInstanceApiKey(this.db, name as InstanceApiKeyName);
if (instance !== null) return instance;
}
return decrypt_api_key(current?.[name]) ?? '';
}
/**
* Which key names this body actually changes.
*
* Compared on cleartext, never on what is stored: the IV is random, so the
* same key encrypted twice differs every time and a ciphertext comparison
* would report a change on every save. The panel saves before each test click,
* so that difference is the one between an audit trail and a full log.
*
* `skipped` are the names a managed install refuses to write — auditing them
* would claim a change that never happened.
*/
private changedKeyNames(
body: Record<string, unknown>,
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
isAdmin: boolean,
skipped: string[] = [],
): string[] {
const norm = (v: unknown) => String(v ?? '').trim();
return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key'] as const).filter(
(name) => body[name] !== undefined && !skipped.includes(name) && norm(body[name]) !== norm(this.storedKeyPlaintext(name, current, isAdmin))
);
}
/**
* Mirror the two instance-wide names into app_settings when an admin saves.
*
* Only an admin: `PUT /me/api-keys` is not admin-gated (the class carries
* JwtAuthGuard alone), so letting any caller write here would hand every
* member the instance credential. A non-admin keeps writing their own column,
* which is still the last step of the resolver.
*/
private mirrorInstanceKeys(body: Record<string, unknown>, isAdmin: boolean): void {
if (!isAdmin) return;
for (const name of INSTANCE_API_KEY_NAMES) {
if (body[name] !== undefined) writeInstanceApiKey(this.db, name, body[name]);
}
}
updateMapsKey(userId: number, key: unknown) {
const maps_api_key = key as string | null | undefined;
if (this.managed) {
return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'] };
return { success: true, maps_api_key: null, managed_keys: ['maps_api_key'], changedKeys: [] };
}
this.db.run(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
maybe_encrypt_api_key(maps_api_key), userId
);
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key) };
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = this.changedKeyNames({ maps_api_key }, current, isAdmin);
this.db.transaction(() => {
this.db.run(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
maybe_encrypt_api_key(maps_api_key), userId
);
this.mirrorInstanceKeys({ maps_api_key }, isAdmin);
});
return { success: true, maps_api_key: mask_stored_api_key(maps_api_key), changedKeys };
}
updateApiKeys(userId: number, rawBody: unknown) {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string };
const { blocked } = splitManagedKeys(body, this.managed);
for (const key of blocked) delete body[key as keyof typeof body];
const current = this.db.get<Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>('SELECT maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?', userId);
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = this.changedKeyNames(body, current, isAdmin, blocked);
// `?? null` instead of the former non-null assertions: a user row deleted
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
this.db.run(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
userId
);
this.db.transaction(() => {
// `?? null` instead of the former non-null assertions: a user row deleted
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
this.db.run(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
userId
);
this.mirrorInstanceKeys(body, isAdmin);
});
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
@@ -79,13 +163,14 @@ export class UserProfileService {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
changedKeys,
};
}
updateSettings(
userId: number,
rawBody: unknown
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown> } {
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] } {
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string };
const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body;
@@ -124,10 +209,19 @@ export class UserProfileService {
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
// Read before the write, so the comparison sees the old value; the role in
// the same row decides whether the two instance-wide names travel with it.
const current = this.currentKeys(userId);
const isAdmin = current?.role === 'admin';
const changedKeys = keyLocked ? [] : this.changedKeyNames(body, current, isAdmin, blocked);
if (updates.length > 0) {
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(userId);
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
this.db.transaction(() => {
this.db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, ...params);
if (!keyLocked) this.mirrorInstanceKeys(body, isAdmin);
});
}
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
@@ -140,6 +234,7 @@ export class UserProfileService {
success: true,
...(blocked.length ? { managed_keys: blocked } : {}),
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
changedKeys,
};
}
@@ -166,11 +261,15 @@ export class UserProfileService {
};
}
// Maps and Unsplash are read where the search reads them: instance-wide
// first. Showing the admin their own column while every request used another
// value is the confusion #1939 reported. Their column still answers while no
// instance value exists — on that install it is what the resolver picks too.
return {
settings: {
maps_api_key: decrypt_api_key(user.maps_api_key),
maps_api_key: readInstanceApiKey(this.db, 'maps_api_key') ?? decrypt_api_key(user.maps_api_key),
openweather_api_key: decrypt_api_key(user.openweather_api_key),
unsplash_api_key: decrypt_api_key(user.unsplash_api_key),
unsplash_api_key: readInstanceApiKey(this.db, 'unsplash_api_key') ?? decrypt_api_key(user.unsplash_api_key),
},
};
}
@@ -226,7 +325,7 @@ export class UserProfileService {
// -------------------------------------------------------------------------
async validateKeys(userId: number): Promise<{ error?: string; status?: number; maps: boolean; weather: boolean; maps_details: null | { ok: boolean; status: number | null; status_text: string | null; error_message: string | null; error_status: string | null; error_raw: string | null } }> {
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'>>('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?', userId);
const user = this.db.get<Pick<User, 'role' | 'openweather_api_key'>>('SELECT role, openweather_api_key FROM users WHERE id = ?', userId);
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403, maps: false, weather: false, maps_details: null };
const result: {
@@ -242,7 +341,10 @@ export class UserProfileService {
};
} = { maps: false, weather: false, maps_details: null };
const maps_api_key = decrypt_api_key(user.maps_api_key);
// The key a search would actually use, not the one in this admin's column:
// testing a value nothing resolves to is how "the panel says the key is
// fine" and "every search 403s" coexisted (#1939).
const { key: maps_api_key } = resolveApiKey(this.db, 'maps_api_key', userId, readEnv().maps.placesApiKey);
if (maps_api_key) {
try {
// Same Referer as maps.service googleFetch — without it, keys with an
@@ -256,7 +358,9 @@ export class UserProfileService {
...(referer ? { Referer: referer } : {}),
'Content-Type': 'application/json',
'X-Goog-Api-Key': maps_api_key,
'X-Goog-FieldMask': 'places.displayName',
// The mask the real search sends. A narrower probe passes on keys
// that are restricted to fewer Places SKUs than TREK asks for.
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify({ textQuery: 'test' }),
}
+11
View File
@@ -22,6 +22,17 @@ export function buildUserAgent(instanceUrl: string | undefined): string {
// Computed once at load — getAppUrl() reads only env vars, which don't change at runtime.
export const UA = buildUserAgent(getAppUrl());
/**
* The fields places:searchText is asked for.
*
* Here rather than beside the call because the admin panel's key test has to
* send the same mask: a key restricted to a narrower set of Places SKUs answers
* a one-field probe with 200 and the real search with 403, which is the second
* way to reach the #1939 report ("test button green, searching fails").
*/
export const SEARCH_TEXT_FIELD_MASK =
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus';
// TREK's internal language codes mostly coincide with valid BCP-47 codes, but a
// couple don't: 'br' is Brazilian Portuguese here (BCP-47 'pt-BR'; bare 'br' is
// Breton) and 'gr' is Greek (BCP-47 'el'). Outbound geo APIs (Google Places,
+37 -23
View File
@@ -9,13 +9,14 @@ import type {
} from '@trek/shared';
import { readEnv, getAppUrl } from '../../app-config';
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
import { DatabaseService } from '../database/database.service';
import { nominatimFetch, type GeoLane } from '../geo/nominatim.client';
import {
UA,
SEARCH_TEXT_FIELD_MASK,
toApiLang,
googleFtidFromMapsUrl,
buildOsmDetails,
@@ -59,6 +60,18 @@ function placesEndpoint(endpoint: string): string {
return base.replace(/\/+$/, '') + endpoint.slice(PLACES_UPSTREAM.length);
}
/**
* Says which of the three credentials Google rejected, never which value.
*
* The response body Google sends ("The caller does not have permission") is
* identical whichever key was used, so without this line a report of "works for
* the admin, fails for everyone else" cannot be told apart from a genuinely
* broken key.
*/
function logKeyFailure(label: string, status: number, userId: number, source: ApiKeySource | null): void {
console.error(`[Maps] ${label} failed with ${status} userId=${userId} keySource=${source}`);
}
function googleFetch(rawEndpoint: string, label: string, init?: RequestInit): Promise<Response> {
const endpoint = placesEndpoint(rawEndpoint);
googleApiCallCount++;
@@ -548,25 +561,25 @@ export class MapsService {
// ── API key retrieval ──────────────────────────────────────────────────────
getMapsKey(userId: number): string | null {
// When the operator supplies the credential, the database is not asked at
// all. Two reasons, and the second is the load-bearing one: a per-user key
// would route around whatever the operator's endpoint counts, and the key
// columns are readable by any admin of this instance, so a shared key stored
// there would be a key handed out. Unset, this branch never runs.
const operatorKey = readEnv().maps.placesApiKey;
if (operatorKey) return operatorKey;
/**
* The Places credential for this request, and where it came from.
*
* Operator env first: a per-user key would route around whatever the
* operator's endpoint counts, and unset, that branch never runs. Then the
* instance-wide value the admin panel writes, then the caller's own row.
*
* What is deliberately gone is the old third step, "any admin's key" (#1939):
* it read a stranger's credential, which server/CLAUDE.md forbids, and made
* the answer depend on who was asking — the saving admin got their own key,
* everybody else got the lowest-id admin's and a 403 from Google. The source
* is returned so a provider error can say which of the three was used.
*/
resolveMapsKey(userId: number): { key: string | null; source: ApiKeySource | null } {
return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey);
}
const user = this.database.get<{ maps_api_key: string | null }>(
'SELECT maps_api_key FROM users WHERE id = ?',
userId,
);
const user_key = decrypt_api_key(user?.maps_api_key);
if (user_key) return user_key;
const admin = this.database.get<{ maps_api_key: string }>(
"SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1",
);
return decrypt_api_key(admin?.maps_api_key) || null;
getMapsKey(userId: number): string | null {
return this.resolveMapsKey(userId).key;
}
// ── Nominatim search ───────────────────────────────────────────────────────
@@ -1417,7 +1430,7 @@ export class MapsService {
lang?: string,
locationBias?: { lat: number; lng: number; radius?: number },
): Promise<{ places: Record<string, unknown>[]; source: string }> {
const apiKey = this.getMapsKey(userId);
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
if (!apiKey) {
const places = await this.searchNominatim(query, lang);
@@ -1441,8 +1454,7 @@ export class MapsService {
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask':
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri,places.businessStatus',
'X-Goog-FieldMask': SEARCH_TEXT_FIELD_MASK,
},
body: JSON.stringify(searchBody),
});
@@ -1450,6 +1462,7 @@ export class MapsService {
const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } };
if (!response.ok) {
logKeyFailure('searchText', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
err.status = response.status;
throw err;
@@ -1488,7 +1501,7 @@ export class MapsService {
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
sessionToken?: string,
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: string }> {
const apiKey = this.getMapsKey(userId);
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
if (!apiKey) {
return this.autocompleteNominatim(input, lang);
@@ -1526,6 +1539,7 @@ export class MapsService {
};
if (!response.ok) {
logKeyFailure('autocomplete', response.status, userId, keySource);
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
err.status = response.status;
throw err;
@@ -0,0 +1,89 @@
import { DatabaseService } from '../database/database.service';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
/**
* The third-party keys that belong to the instance rather than to a person.
*
* Google Places and Unsplash bill the install, not the account that pasted the
* key in, so the value is instance configuration — the same argument #1772 made
* for the LLM endpoint (see llm-parse/llm-config.ts: instance-wide, admin-set,
* wins; per-user as the fallback). Both keys used to be resolved out of the
* `users` columns alone, which meant "the key of the admin with the lowest id"
* for everyone else and produced #1939: the admin searched with the key they
* had just saved, every other member searched with a stranger's and got a 403
* from Google.
*
* Stored as an encrypted `app_settings` row under the name below, reusing
* apiKeyCrypto, so the format matches what the users columns already hold and
* a legacy plaintext value still reads back.
*/
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key';
/** Instance names whose per-user column is still honoured as a last resort. */
export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = ['maps_api_key', 'unsplash_api_key'];
/**
* Where a resolved key came from. Logged beside a provider error so "works for
* the admin, 403 for everyone else" is one line in the log rather than a
* guessing game; the key itself is never logged.
*/
export type ApiKeySource = 'operator-env' | 'instance' | 'user-row';
// Full statements rather than an interpolated column: the name doubles as the
// users column AND the app_settings key, and identifiers only ever come from a
// literal allow-list.
const USER_ROW_SQL: Record<InstanceApiKeyName, string> = {
maps_api_key: 'SELECT maps_api_key FROM users WHERE id = ?',
unsplash_api_key: 'SELECT unsplash_api_key FROM users WHERE id = ?',
};
/** The instance-wide value in cleartext, or null when unset/cleared. */
export function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): string | null {
const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', name);
if (!row?.value) return null;
return decrypt_api_key(row.value) || null;
}
/**
* Store the instance-wide value, encrypted. A blank value stores '' rather than
* deleting the row: an admin who clears the field means "this instance has no
* key", and a missing row would let the resolver fall through to whatever old
* value still sits in their own users column.
*/
export function writeInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName, value: unknown): void {
db.run(
`INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
name, maybe_encrypt_api_key(value) ?? ''
);
}
/**
* Resolve a key for one request: operator env → instance-wide → the caller's
* own row.
*
* The caller's own row is last, not second, and no other user's row is ever
* read — falling back to a stranger's key is what server/CLAUDE.md forbids and
* what #1939 reported. It stays as a fallback because `PUT /me/api-keys` has
* always accepted a personal key and dropping it would break the installs where
* each member pays for their own.
*
* `userId` 0 means "nobody is asking" (the unauthenticated app-config read):
* there is no personal key to find, so the chain ends at the instance.
*/
export function resolveApiKey(
db: DatabaseService,
name: InstanceApiKeyName,
userId: number,
operatorKey: string | undefined,
): { key: string | null; source: ApiKeySource | null } {
if (operatorKey) return { key: operatorKey, source: 'operator-env' };
const instance = readInstanceApiKey(db, name);
if (instance) return { key: instance, source: 'instance' };
if (!userId) return { key: null, source: null };
const row = db.get<Record<string, string | null>>(USER_ROW_SQL[name], userId);
const own = decrypt_api_key(row?.[name]) || null;
return own ? { key: own, source: 'user-row' } : { key: null, source: null };
}
+8 -12
View File
@@ -3,7 +3,7 @@ import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { Injectable } from '@nestjs/common';
import { safeFetch } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { resolveApiKey } from '../settings/instance-api-keys';
import { DatabaseService } from '../database/database.service';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
@@ -42,7 +42,7 @@ const COVER_EXT_BY_TYPE: Record<string, string> = {
* Unsplash search and cover download.
*
* Its own module rather than a method on trips or places: both call it, and the
* key resolution reads the env AND the users table, so it needs the injected
* key resolution reads the env AND the database, so it needs the injected
* connection either way.
*/
@Injectable()
@@ -55,18 +55,14 @@ export class UnsplashService {
/**
* Resolve an Unsplash access key for a request, in precedence order:
* the `UNSPLASH_ACCESS_KEY` env var (instance-wide override, matching the
* SMTP/OIDC env-over-DB convention) → the caller's own stored key → any
* admin's stored key. Mirrors `getMapsKey`. Returns null when none is set,
* in which case the search falls back to the unauthenticated endpoint.
* SMTP/OIDC env-over-DB convention) → the instance-wide value the admin panel
* saves → the caller's own stored key. Mirrors `resolveMapsKey`, including what
* it dropped: "any admin's stored key" read a stranger's credential and made
* the answer depend on who asked (#1939). Returns null when none is set, in
* which case the search falls back to the unauthenticated endpoint.
*/
getUnsplashKey(userId: number): string | null {
const env_key = this.env.env().integrations.unsplashAccessKey;
if (env_key) return env_key;
const user = this.db.get<{ unsplash_api_key: string | null }>('SELECT unsplash_api_key FROM users WHERE id = ?', userId);
const user_key = decrypt_api_key(user?.unsplash_api_key);
if (user_key) return user_key;
const admin = this.db.get<{ unsplash_api_key: string }>("SELECT unsplash_api_key FROM users WHERE role = 'admin' AND unsplash_api_key IS NOT NULL AND unsplash_api_key != '' LIMIT 1");
return decrypt_api_key(admin?.unsplash_api_key) || null;
return resolveApiKey(this.db, 'unsplash_api_key', userId, this.env.env().integrations.unsplashAccessKey).key;
}
async searchUnsplashPhotos(query: string, perPage = 9, accessKey?: string | null) {