feat(settings): set the Google Maps key through PLACES_API_KEY

The server already put PLACES_API_KEY ahead of the key saved in the admin
panel, but nothing said so: the variable was in no template and the admin
field kept showing a stored value that no search used.

GET /me/settings now answers env_keys, the variable behind each key the
environment sets (Google, Unsplash, Amap), and null for that key's value.
The admin field turns read-only and names the variable, Test still probes
the key a search resolves to, and a save leaves those names out so the
stored fallback survives. The variable is documented in .env.example,
docker-compose.yml, the Helm chart (as a secret) and the wiki.

Addresses discussion #1881.
This commit is contained in:
Maurice
2026-09-24 16:17:54 +02:00
parent 79cd3095e0
commit 8906b66bc8
45 changed files with 308 additions and 36 deletions
+13 -3
View File
@@ -8,6 +8,7 @@ import { EMAIL_REGEX, mask_stored_api_key } from './auth.helpers';
import { splitManagedKeys, MANAGED_LOCKED_PROFILE_KEYS } from '../common/managed';
import {
INSTANCE_API_KEY_NAMES,
operatorKeyVariables,
readInstanceApiKey,
resolveApiKey,
writeInstanceApiKey,
@@ -270,12 +271,21 @@ export class UserProfileService {
// 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.
//
// Ahead of both sits the operator's environment variable. A key set there
// comes back as null plus the variable's name in `env_keys`: the stored
// value is not what any search uses, and the variable's value is the
// operator's, not the panel's to hand out (#1881).
const envKeys = operatorKeyVariables();
const shown = (name: InstanceApiKeyName, own: unknown) =>
envKeys[name] ? null : (readInstanceApiKey(this.db, name) ?? decrypt_api_key(own));
return {
settings: {
maps_api_key: readInstanceApiKey(this.db, 'maps_api_key') ?? decrypt_api_key(user.maps_api_key),
maps_api_key: shown('maps_api_key', user.maps_api_key),
openweather_api_key: decrypt_api_key(user.openweather_api_key),
unsplash_api_key: readInstanceApiKey(this.db, 'unsplash_api_key') ?? decrypt_api_key(user.unsplash_api_key),
amap_api_key: readInstanceApiKey(this.db, 'amap_api_key') ?? decrypt_api_key(user.amap_api_key),
unsplash_api_key: shown('unsplash_api_key', user.unsplash_api_key),
amap_api_key: shown('amap_api_key', user.amap_api_key),
env_keys: envKeys,
},
};
}
@@ -1,3 +1,4 @@
import { readEnv } from '../../app-config';
import { DatabaseService } from '../database/database.service';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import type { ApiKeySource } from '@trek/shared';
@@ -46,6 +47,26 @@ const USER_ROW_SQL: Record<InstanceApiKeyName, string> = {
amap_api_key: 'SELECT amap_api_key FROM users WHERE id = ?',
};
/**
* The environment variable that overrides each instance key. Whatever it holds
* is the operator key resolveApiKey puts first, so the admin panel names the
* variable rather than showing a field that nothing reads (#1881).
*/
const OPERATOR_KEY_ENV: Record<InstanceApiKeyName, { variable: string; read: () => string | undefined }> = {
maps_api_key: { variable: 'PLACES_API_KEY', read: () => readEnv().maps.placesApiKey },
unsplash_api_key: { variable: 'UNSPLASH_ACCESS_KEY', read: () => readEnv().integrations.unsplashAccessKey },
amap_api_key: { variable: 'AMAP_API_KEY', read: () => readEnv().maps.amapApiKey },
};
/** The instance keys an environment variable sets, each with that variable's name. Never the value. */
export function operatorKeyVariables(): Partial<Record<InstanceApiKeyName, string>> {
const set: Partial<Record<InstanceApiKeyName, string>> = {};
for (const name of INSTANCE_API_KEY_NAMES) {
if (OPERATOR_KEY_ENV[name].read()) set[name] = OPERATOR_KEY_ENV[name].variable;
}
return set;
}
/** 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);