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
+6
View File
@@ -75,6 +75,12 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: UNSPLASH_ACCESS_KEY key: UNSPLASH_ACCESS_KEY
optional: true optional: true
- name: PLACES_API_KEY
valueFrom:
secretKeyRef:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: PLACES_API_KEY
optional: true
- name: AMAP_API_KEY - name: AMAP_API_KEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
+6
View File
@@ -20,6 +20,9 @@ data:
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }} UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }} {{- end }}
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY | b64enc | quote }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }} {{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }} AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
{{- end }} {{- end }}
@@ -56,6 +59,9 @@ stringData:
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }} UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
{{- end }} {{- end }}
{{- if .Values.secretEnv.PLACES_API_KEY }}
PLACES_API_KEY: {{ .Values.secretEnv.PLACES_API_KEY }}
{{- end }}
{{- if .Values.secretEnv.AMAP_API_KEY }} {{- if .Values.secretEnv.AMAP_API_KEY }}
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }} AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }}
{{- end }} {{- end }}
+5
View File
@@ -133,6 +133,11 @@ secretEnv:
# Get a free key at https://unsplash.com/developers. Can also be set per-admin # Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable. # in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY: "" UNSPLASH_ACCESS_KEY: ""
# Optional Google Maps API key (Places API (New) enabled) for place search, photos,
# details and Google transit. Overrides the key set in Admin > Settings > API Keys,
# whose field then shows this variable read-only. Leave empty to use the key from the
# admin panel, or none.
PLACES_API_KEY: ""
# Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland # Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland
# China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is # China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is
# a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the # a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the
@@ -44,7 +44,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
passkeyLogin, setPasskeyLogin, passkeyConfigured, passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn, webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, savingKeys, validating, validation, mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider, placesProvider, savingPlacesProvider, handleSavePlacesProvider,
managed, managed,
setShowRotateJwtModal, setShowRotateJwtModal,
@@ -245,12 +245,12 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
aria-label={t('admin.mapsKey')} aria-label={t('admin.mapsKey')}
value={mapsKey} value={mapsKey}
onChange={(e) => setMapsKey(e.target.value)} onChange={(e) => setMapsKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')} {...keyInputProps('maps')}
/> />
</div> </div>
<MAdminButton <MAdminButton
variant="ghost" variant="ghost"
disabled={!mapsKey} disabled={!mapsKeyTestable}
busy={!!validating.maps} busy={!!validating.maps}
onClick={() => handleValidateKey('maps')} onClick={() => handleValidateKey('maps')}
className="h-[42px]" className="h-[42px]"
@@ -374,7 +374,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
aria-label={t('admin.amapKey')} aria-label={t('admin.amapKey')}
value={amapKey} value={amapKey}
onChange={(e) => setAmapKey(e.target.value)} onChange={(e) => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')} {...keyInputProps('amap')}
/> />
</MProviderBlock> </MProviderBlock>
@@ -384,7 +384,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
aria-label={t('admin.unsplashKey')} aria-label={t('admin.unsplashKey')}
value={unsplashKey} value={unsplashKey}
onChange={(e) => setUnsplashKey(e.target.value)} onChange={(e) => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')} {...keyInputProps('unsplash')}
/> />
</MProviderBlock> </MProviderBlock>
</>)} </>)}
+1 -1
View File
@@ -75,7 +75,7 @@ export function MAdminInput({ className = '', ...props }: InputHTMLAttributes<HT
return ( return (
<input <input
{...props} {...props}
className={`h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] ${className}`} className={`h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
/> />
) )
} }
@@ -1,4 +1,4 @@
// FE-ADMSET-001 to FE-ADMSET-042 // FE-ADMSET-001 to FE-ADMSET-043
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import React from 'react'; import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -339,6 +339,21 @@ describe('AdminSettingsTab', () => {
expect(within(card('API Keys')).getByRole('button', { name: /^test$/i })).toBeDisabled(); expect(within(card('API Keys')).getByRole('button', { name: /^test$/i })).toBeDisabled();
}); });
it('FE-ADMSET-043: a key the environment sets is read-only and names its variable (#1881)', () => {
const admin = renderTab({
keyInputProps: (field: string) =>
field === 'maps' ? { disabled: true, placeholder: 'Set via PLACES_API_KEY' } : { disabled: false, placeholder: 'Enter key...' },
mapsKeyTestable: true,
});
expect(keyInput('Google Maps API Key')).toBeDisabled();
expect(keyInput('Google Maps API Key')).toHaveAttribute('placeholder', 'Set via PLACES_API_KEY');
expect(keyInput(/unsplash/i)).toBeEnabled();
// The empty field still tests: the probe uses the key a search resolves to.
fireEvent.click(within(card('API Keys')).getByRole('button', { name: /^test$/i }));
expect(admin.handleValidateKey).toHaveBeenCalledWith('maps');
});
it('FE-ADMSET-024: the maps Test button validates the maps key', () => { it('FE-ADMSET-024: the maps Test button validates the maps key', () => {
const admin = renderTab({ mapsKey: 'AIza-test' }); const admin = renderTab({ mapsKey: 'AIza-test' });
+8 -8
View File
@@ -37,7 +37,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
passkeyLogin, setPasskeyLogin, passkeyConfigured, passkeyLogin, setPasskeyLogin, passkeyConfigured,
webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn, webauthnRpId, setWebauthnRpId, webauthnOrigins, setWebauthnOrigins, savingWebauthn, handleSaveWebauthn,
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, showKeys, savingKeys, validating, validation, mapsKey, setMapsKey, unsplashKey, setUnsplashKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable, showKeys, savingKeys, validating, validation,
placesProvider, savingPlacesProvider, handleSavePlacesProvider, placesProvider, savingPlacesProvider, handleSavePlacesProvider,
managed, managed,
setShowRotateJwtModal, setShowRotateJwtModal,
@@ -401,8 +401,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
type={showKeys.maps ? 'text' : 'password'} type={showKeys.maps ? 'text' : 'password'}
value={mapsKey} value={mapsKey}
onChange={e => setMapsKey(e.target.value)} onChange={e => setMapsKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')} {...keyInputProps('maps')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent" className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed"
/> />
<button <button
type="button" type="button"
@@ -415,7 +415,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</div> </div>
<button type="button" <button type="button"
onClick={() => handleValidateKey('maps')} onClick={() => handleValidateKey('maps')}
disabled={!mapsKey || validating.maps} disabled={!mapsKeyTestable || validating.maps}
className="px-3 py-2 text-sm border border-edge rounded-lg hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 text-content-secondary" className="px-3 py-2 text-sm border border-edge rounded-lg hover:bg-surface-hover disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5 text-content-secondary"
> >
{validating.maps ? ( {validating.maps ? (
@@ -549,8 +549,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
type={showKeys.unsplash ? 'text' : 'password'} type={showKeys.unsplash ? 'text' : 'password'}
value={unsplashKey} value={unsplashKey}
onChange={e => setUnsplashKey(e.target.value)} onChange={e => setUnsplashKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')} {...keyInputProps('unsplash')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent" className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed"
/> />
<button <button
type="button" type="button"
@@ -575,8 +575,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
type={showKeys.amap ? 'text' : 'password'} type={showKeys.amap ? 'text' : 'password'}
value={amapKey} value={amapKey}
onChange={e => setAmapKey(e.target.value)} onChange={e => setAmapKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')} {...keyInputProps('amap')}
className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent" className="w-full pr-10 px-3 py-2 border border-edge rounded-lg text-sm bg-surface-input text-content focus:ring-2 focus:ring-accent focus:border-transparent disabled:opacity-60 disabled:cursor-not-allowed"
/> />
<button <button
type="button" type="button"
+45 -1
View File
@@ -1,4 +1,4 @@
// FE-ADMHOOK-001 to FE-ADMHOOK-051 // FE-ADMHOOK-001 to FE-ADMHOOK-053
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import React from 'react'; import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -378,6 +378,50 @@ describe('useAdmin', () => {
expect(result.current.savingKeys).toBe(false); expect(result.current.savingKeys).toBe(false);
}); });
it('FE-ADMHOOK-052: a key set by the environment locks its field and stays testable (#1881)', async () => {
server.use(
http.get('/api/auth/me/settings', () =>
HttpResponse.json({
settings: { maps_api_key: null, unsplash_api_key: 'unsplash-k', env_keys: { maps_api_key: 'PLACES_API_KEY' } },
})
)
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.keyInputProps('maps').disabled).toBe(true));
expect(result.current.keyInputProps('maps').placeholder).toBe('Set via PLACES_API_KEY');
expect(result.current.keyInputProps('unsplash')).toEqual({ disabled: false, placeholder: 'Enter key...' });
// Test probes what a search resolves to, so the empty field does not block it.
expect(result.current.mapsKey).toBe('');
expect(result.current.mapsKeyTestable).toBe(true);
});
it('FE-ADMHOOK-053: saving leaves a key the environment sets out of the body', async () => {
let body: Record<string, unknown> | null = null;
server.use(
http.get('/api/auth/me/settings', () =>
HttpResponse.json({ settings: { maps_api_key: null, env_keys: { maps_api_key: 'PLACES_API_KEY' } } })
),
http.put('/api/auth/me/api-keys', async ({ request }) => {
body = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ success: true });
})
);
const { result } = await mountAdmin();
await waitFor(() => expect(result.current.mapsKeyTestable).toBe(true));
act(() => result.current.setUnsplashKey('u'));
await act(async () => {
await result.current.handleSaveApiKeys();
});
// Sending the empty field would clear the stored value the install falls
// back to once the variable is removed.
expect(body).not.toHaveProperty('maps_api_key');
expect(body).toMatchObject({ unsplash_api_key: 'u' });
});
it('FE-ADMHOOK-020: handleSaveApiKeys surfaces the thrown error message', async () => { it('FE-ADMHOOK-020: handleSaveApiKeys surfaces the thrown error message', async () => {
server.use(http.put('/api/auth/me/api-keys', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); server.use(http.put('/api/auth/me/api-keys', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
const { result } = await mountAdmin(); const { result } = await mountAdmin();
+27 -9
View File
@@ -179,6 +179,8 @@ export function useAdmin() {
const [weatherKey, setWeatherKey] = useState<string>('') const [weatherKey, setWeatherKey] = useState<string>('')
const [unsplashKey, setUnsplashKey] = useState<string>('') const [unsplashKey, setUnsplashKey] = useState<string>('')
const [amapKey, setAmapKey] = useState<string>('') const [amapKey, setAmapKey] = useState<string>('')
// Keys the operator set in an environment variable: key name → variable (#1881).
const [envKeys, setEnvKeys] = useState<Record<string, string>>({})
/** /**
* Which provider answers place search. Not a key, so it saves through * Which provider answers place search. Not a key, so it saves through
* updateAppSettings rather than with the keys — and it is saved on change * updateAppSettings rather than with the keys — and it is saved on change
@@ -269,6 +271,7 @@ export function useAdmin() {
setWeatherKey(data.settings?.openweather_api_key || '') setWeatherKey(data.settings?.openweather_api_key || '')
setUnsplashKey(data.settings?.unsplash_api_key || '') setUnsplashKey(data.settings?.unsplash_api_key || '')
setAmapKey(data.settings?.amap_api_key || '') setAmapKey(data.settings?.amap_api_key || '')
setEnvKeys(data.settings?.env_keys ?? {})
} catch (err: unknown) { } catch (err: unknown) {
// ignore // ignore
} }
@@ -317,15 +320,30 @@ export function useAdmin() {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] })) setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
} }
// A key the environment sets leaves its field empty, and saving that '' would
// clear the stored value the install falls back to once the variable is gone.
// So those names stay out of every save.
const apiKeysBody = () => Object.fromEntries(
Object.entries({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey })
.filter(([name]) => !envKeys[name]),
)
/** A key field's lock: read-only, naming the variable, while the environment sets that key. */
const keyInputProps = (field: 'maps' | 'unsplash' | 'amap') => {
const variable = envKeys[`${field}_api_key`]
return {
disabled: !!variable,
placeholder: variable ? t('admin.keyFromEnv', { name: variable }) : t('settings.keyPlaceholder'),
}
}
// Test probes the key a search resolves to, so one from the environment is
// testable with the field left empty.
const mapsKeyTestable = !!(mapsKey || envKeys.maps_api_key)
const handleSaveApiKeys = async () => { const handleSaveApiKeys = async () => {
setSavingKeys(true) setSavingKeys(true)
try { try {
await updateApiKeys({ await updateApiKeys(apiKeysBody())
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
unsplash_api_key: unsplashKey,
amap_api_key: amapKey,
})
toast.success(t('admin.keySaved')) toast.success(t('admin.keySaved'))
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : 'Unknown error') toast.error(err instanceof Error ? err.message : 'Unknown error')
@@ -338,7 +356,7 @@ export function useAdmin() {
setValidating({ maps: true, weather: true }) setValidating({ maps: true, weather: true })
try { try {
// Save first so validation uses the current values // Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey }) await updateApiKeys(apiKeysBody())
const result = await authApi.validateKeys() const result = await authApi.validateKeys()
setValidation(result) setValidation(result)
} catch (err: unknown) { } catch (err: unknown) {
@@ -352,7 +370,7 @@ export function useAdmin() {
setValidating(prev => ({ ...prev, [keyType]: true })) setValidating(prev => ({ ...prev, [keyType]: true }))
try { try {
// Save first so validation uses the current values // Save first so validation uses the current values
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey, unsplash_api_key: unsplashKey, amap_api_key: amapKey }) await updateApiKeys(apiKeysBody())
const result = await authApi.validateKeys() const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] })) setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err: unknown) { } catch (err: unknown) {
@@ -517,7 +535,7 @@ export function useAdmin() {
allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes, allowedFileTypes, setAllowedFileTypes, savingFileTypes, setSavingFileTypes,
smtpValues, setSmtpValues, smtpLoaded, smtpValues, setSmtpValues, smtpLoaded,
mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey, mapsKey, setMapsKey, weatherKey, setWeatherKey, unsplashKey, setUnsplashKey,
amapKey, setAmapKey, hasMapsKey, hasAmapKey, amapKey, setAmapKey, hasMapsKey, hasAmapKey, keyInputProps, mapsKeyTestable,
placesProvider, savingPlacesProvider, handleSavePlacesProvider, placesProvider, savingPlacesProvider, handleSavePlacesProvider,
showKeys, setShowKeys, savingKeys, validating, validation, showKeys, setShowKeys, savingKeys, validating, validation,
updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal, updateInfo, setUpdateInfo, showUpdateModal, setShowUpdateModal,
+6 -1
View File
@@ -150,6 +150,8 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
setAmapKey: vi.fn(), setAmapKey: vi.fn(),
hasMapsKey: false, hasMapsKey: false,
hasAmapKey: false, hasAmapKey: false,
// No key set by the environment unless a test says so (#1881).
keyInputProps: () => ({ disabled: false, placeholder: 'Enter key...' }),
placesProvider: 'auto', placesProvider: 'auto',
savingPlacesProvider: false, savingPlacesProvider: false,
showKeys: {}, showKeys: {},
@@ -188,5 +190,8 @@ export function buildAdminHook(overrides: Record<string, unknown> = {}): AdminHo
handleDeleteUser: vi.fn(), handleDeleteUser: vi.fn(),
}; };
return { ...base, ...overrides } as unknown as AdminHook; // Derived like the hook does, so a test that only sets mapsKey keeps its Test button.
const merged = { ...base, ...overrides };
if (!('mapsKeyTestable' in overrides)) merged.mapsKeyTestable = !!merged.mapsKey;
return merged as unknown as AdminHook;
} }
@@ -1,4 +1,4 @@
// FE-MOB-ASET-001 to FE-MOB-ASET-023 // FE-MOB-ASET-001 to FE-MOB-ASET-024
import React from 'react'; import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
@@ -234,6 +234,21 @@ describe('MAdminSettingsSection', () => {
expect(admin.handleValidateKey).toHaveBeenCalledWith('maps'); expect(admin.handleValidateKey).toHaveBeenCalledWith('maps');
}); });
it('FE-MOB-ASET-024: a key the environment sets is read-only and names its variable (#1881)', async () => {
const user = userEvent.setup();
const admin = renderSettings({
keyInputProps: (field: string) =>
field === 'maps' ? { disabled: true, placeholder: 'Set via PLACES_API_KEY' } : { disabled: false, placeholder: 'Enter key...' },
mapsKeyTestable: true,
});
expect(screen.getByLabelText('Google Maps API Key')).toBeDisabled();
expect(screen.getByLabelText('Google Maps API Key')).toHaveAttribute('placeholder', 'Set via PLACES_API_KEY');
expect(screen.getByLabelText('Unsplash API Key')).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Test' }));
expect(admin.handleValidateKey).toHaveBeenCalledWith('maps');
});
it('FE-MOB-ASET-015: the validation result renders as a valid or invalid line', () => { it('FE-MOB-ASET-015: the validation result renders as a valid or invalid line', () => {
const { unmount } = render( const { unmount } = render(
<Harness admin={buildAdminHook({ mapsKey: 'k', validation: { maps: true } })} />, <Harness admin={buildAdminHook({ mapsKey: 'k', validation: { maps: true } })} />,
+1
View File
@@ -55,6 +55,7 @@ services:
# - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that. # - UNSPLASH_ACCESS_KEY= # Optional. Unsplash Access Key for trip-cover / place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from (#1449). Free key at unsplash.com/developers. Can also be set per-admin in Admin > Settings; the env var overrides that.
# - TREK_PLACES_ENABLED=false # Optional. Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) asks the TREK Places API at https://places.liketrek.com by default, so the server makes outbound requests to that host. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing there. No switch in the admin panel on purpose: whether searches leave the instance is decided here. (default: true) # - TREK_PLACES_ENABLED=false # Optional. Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) asks the TREK Places API at https://places.liketrek.com by default, so the server makes outbound requests to that host. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing there. No switch in the admin panel on purpose: whether searches leave the instance is decided here. (default: true)
# - TREK_PLACES_URL=https://places.liketrek.com # Optional. Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com) # - TREK_PLACES_URL=https://places.liketrek.com # Optional. Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com)
# - PLACES_API_KEY= # Optional. Google Maps API key (Places API (New) enabled) for place search, photos, details and Google transit. Overrides the key set in Admin > Settings > API Keys, whose field then shows this variable read-only.
# - AMAP_API_KEY= # Optional. Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY. # - AMAP_API_KEY= # Optional. Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY.
# - AMAP_API_SECRET= # Optional. The private secret (数字签名) of that key. Required exactly when the key was created with signing enabled, because such a key rejects every unsigned request. Leave unset otherwise. # - AMAP_API_SECRET= # Optional. The private secret (数字签名) of that key. Required exactly when the key was created with signing enabled, because such a key rejects every unsigned request. Leave unset otherwise.
# - AMAP_API_BASE=https://restapi.amap.com # Optional. Send the Amap calls through an egress proxy, a cache or a gateway that speaks the same API instead of restapi.amap.com directly. (default: https://restapi.amap.com) # - AMAP_API_BASE=https://restapi.amap.com # Optional. Send the Amap calls through an egress proxy, a cache or a gateway that speaks the same API instead of restapi.amap.com directly. (default: https://restapi.amap.com)
+2
View File
@@ -58,6 +58,8 @@ DEMO_MODE=false # Demo mode - resets data hourly
# TREK_PLACES_ENABLED=false # Whether place search asks the TREK Places API at https://places.liketrek.com: suggestions, the full search, the category buttons on the map, the Road trip search along the route, import geocoding and offline downloads. It is on by default and has no switch in the admin panel, because whether searches leave the instance is a deployment decision. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing to that host. (default: true) # TREK_PLACES_ENABLED=false # Whether place search asks the TREK Places API at https://places.liketrek.com: suggestions, the full search, the category buttons on the map, the Road trip search along the route, import geocoding and offline downloads. It is on by default and has no switch in the admin panel, because whether searches leave the instance is a deployment decision. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing to that host. (default: true)
# TREK_PLACES_URL=https://places.liketrek.com # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com) # TREK_PLACES_URL=https://places.liketrek.com # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com)
# PLACES_API_KEY= # Google Maps API key (Places API (New) enabled) for place search, photos, details and Google transit. Overrides the key set in Admin > Settings > API Keys, whose field then shows this variable read-only. The value never reaches the browser.
# AMAP_API_KEY= # Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY. # AMAP_API_KEY= # Amap (高德地图) Web 服务 key for place search and geocoding inside mainland China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is a different credential and is rejected with INVALID_USER_KEY.
# AMAP_API_SECRET= # The private secret (数字签名) of that key. Required exactly when the key was created with signing enabled, because such a key rejects every unsigned request. Leave unset otherwise. # AMAP_API_SECRET= # The private secret (数字签名) of that key. Required exactly when the key was created with signing enabled, because such a key rejects every unsigned request. Leave unset otherwise.
# AMAP_API_BASE=https://restapi.amap.com # Send the Amap calls through an egress proxy, a cache or a gateway that speaks the same API instead of restapi.amap.com directly. (default: https://restapi.amap.com) # AMAP_API_BASE=https://restapi.amap.com # Send the Amap calls through an egress proxy, a cache or a gateway that speaks the same API instead of restapi.amap.com directly. (default: https://restapi.amap.com)
+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 { splitManagedKeys, MANAGED_LOCKED_PROFILE_KEYS } from '../common/managed';
import { import {
INSTANCE_API_KEY_NAMES, INSTANCE_API_KEY_NAMES,
operatorKeyVariables,
readInstanceApiKey, readInstanceApiKey,
resolveApiKey, resolveApiKey,
writeInstanceApiKey, writeInstanceApiKey,
@@ -270,12 +271,21 @@ export class UserProfileService {
// first. Showing the admin their own column while every request used another // first. Showing the admin their own column while every request used another
// value is the confusion #1939 reported. Their column still answers while no // 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. // 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 { return {
settings: { 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), 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), unsplash_api_key: shown('unsplash_api_key', user.unsplash_api_key),
amap_api_key: readInstanceApiKey(this.db, 'amap_api_key') ?? decrypt_api_key(user.amap_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 { DatabaseService } from '../database/database.service';
import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import type { ApiKeySource } from '@trek/shared'; 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 = ?', 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. */ /** The instance-wide value in cleartext, or null when unset/cleared. */
export function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): string | null { export function readInstanceApiKey(db: DatabaseService, name: InstanceApiKeyName): string | null {
const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', name); const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', name);
+16
View File
@@ -292,6 +292,22 @@ describe('Auth e2e (real auth guard + real service + real cookie service + temp
expect(auditRows('settings.api_keys_update')).toBe(before + 1); expect(auditRows('settings.api_keys_update')).toBe(before + 1);
}, 10000); }, 10000);
it('GET /me/settings names the variable behind a key set in the environment, never its value (#1881)', async () => {
const admin = createUser(db as never, { username: 'env-keys-admin', email: 'env-keys-admin@example.test', role: 'admin' });
const prev = process.env.PLACES_API_KEY;
process.env.PLACES_API_KEY = 'e2e-google-from-env';
try {
const res = await request(server).get('/api/auth/me/settings').set('Cookie', sessionCookie(admin.user.id));
expect(res.status).toBe(200);
expect(res.body.settings.maps_api_key).toBeNull();
expect(res.body.settings.env_keys).toEqual({ maps_api_key: 'PLACES_API_KEY' });
expect(JSON.stringify(res.body)).not.toContain('e2e-google-from-env');
} finally {
if (prev === undefined) delete process.env.PLACES_API_KEY;
else process.env.PLACES_API_KEY = prev;
}
});
it('GET /app-config answers has_maps_key from the instance row, never from an admin column (#1939)', async () => { it('GET /app-config answers has_maps_key from the instance row, never from an admin column (#1939)', async () => {
const member = createUser(db as never, { username: 'keys-member', email: 'keys-member@example.test' }); const member = createUser(db as never, { username: 'keys-member', email: 'keys-member@example.test' });
const admin = createUser(db as never, { username: 'keys-cfg-admin', email: 'keys-cfg-admin@example.test', role: 'admin' }); const admin = createUser(db as never, { username: 'keys-cfg-admin', email: 'keys-cfg-admin@example.test', role: 'admin' });
@@ -38,7 +38,7 @@ describe('OVERPASS_TIMEOUT_MS in the templates', () => {
describe('the Places and Amap switches in the templates', () => { describe('the Places and Amap switches in the templates', () => {
// The index is asked by default and the only way to stop that is this // The index is asked by default and the only way to stop that is this
// variable, so an operator has to be able to find it where they look. // variable, so an operator has to be able to find it where they look.
const documented = ['TREK_PLACES_ENABLED', 'TREK_PLACES_URL', 'AMAP_API_KEY', 'AMAP_API_SECRET', 'AMAP_API_BASE']; const documented = ['TREK_PLACES_ENABLED', 'TREK_PLACES_URL', 'PLACES_API_KEY', 'AMAP_API_KEY', 'AMAP_API_SECRET', 'AMAP_API_BASE'];
it('.env.example documents every one of them', () => { it('.env.example documents every one of them', () => {
for (const name of documented) { for (const name of documented) {
@@ -66,12 +66,12 @@ describe('the Places and Amap switches in the templates', () => {
expect(helmConfigMap).toContain('{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}'); expect(helmConfigMap).toContain('{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}');
}); });
it('the Helm chart carries the Amap credentials in the Secret, never in the ConfigMap', () => { it('the Helm chart carries the Google and Amap credentials in the Secret, never in the ConfigMap', () => {
// A ConfigMap is readable by anyone who can read the namespace; the key and // A ConfigMap is readable by anyone who can read the namespace; the key and
// its signing secret belong next to the other credentials, in both the // its signing secret belong next to the other credentials, in both the
// plain and the generated branch of the Secret, and the pod reads them from // plain and the generated branch of the Secret, and the pod reads them from
// there like it reads the Unsplash key. // there like it reads the Unsplash key.
for (const name of ['AMAP_API_KEY', 'AMAP_API_SECRET']) { for (const name of ['PLACES_API_KEY', 'AMAP_API_KEY', 'AMAP_API_SECRET']) {
expect(helmValues).toContain(` ${name}: ""`); expect(helmValues).toContain(` ${name}: ""`);
expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} | b64enc | quote }}`); expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} | b64enc | quote }}`);
expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} }}`); expect(helmSecret).toContain(`${name}: {{ .Values.secretEnv.${name} }}`);
@@ -32,6 +32,7 @@ import {
readInstanceApiKey, readInstanceApiKey,
writeInstanceApiKey, writeInstanceApiKey,
resolveApiKey, resolveApiKey,
operatorKeyVariables,
} from '../../../../src/nest/settings/instance-api-keys'; } from '../../../../src/nest/settings/instance-api-keys';
const db = new DatabaseService(testDb); const db = new DatabaseService(testDb);
@@ -137,3 +138,43 @@ describe('instance API keys', () => {
expect(resolveApiKey(db, 'unsplash_api_key', user.id, undefined)).toEqual({ key: null, source: null }); expect(resolveApiKey(db, 'unsplash_api_key', user.id, undefined)).toEqual({ key: null, source: null });
}); });
}); });
describe('operatorKeyVariables (#1881)', () => {
const VARS = ['PLACES_API_KEY', 'UNSPLASH_ACCESS_KEY', 'AMAP_API_KEY'] as const;
const saved: Partial<Record<(typeof VARS)[number], string>> = {};
beforeEach(() => {
for (const v of VARS) {
saved[v] = process.env[v];
delete process.env[v];
}
});
afterAll(() => {
for (const v of VARS) {
if (saved[v] === undefined) delete process.env[v];
else process.env[v] = saved[v];
}
});
it('INSTKEY-010: names nothing while no variable is set', () => {
expect(operatorKeyVariables()).toEqual({});
});
it('INSTKEY-011: names the variable behind each key that has one, never its value', () => {
process.env.PLACES_API_KEY = 'google-from-env';
process.env.UNSPLASH_ACCESS_KEY = 'unsplash-from-env';
process.env.AMAP_API_KEY = 'amap-from-env';
const set = operatorKeyVariables();
expect(set).toEqual({
maps_api_key: 'PLACES_API_KEY',
unsplash_api_key: 'UNSPLASH_ACCESS_KEY',
amap_api_key: 'AMAP_API_KEY',
});
expect(JSON.stringify(set)).not.toContain('from-env');
});
it('INSTKEY-012: a blank variable does not count as set', () => {
process.env.PLACES_API_KEY = '';
process.env.UNSPLASH_ACCESS_KEY = ' ';
expect(operatorKeyVariables()).toEqual({});
});
});
@@ -157,6 +157,30 @@ describe('getSettings', () => {
// getSettings returns the stored key to the admin. // getSettings returns the stored key to the admin.
expect(profile.getSettings(user.id).settings?.unsplash_api_key).toBe('unsplash-secret-key'); expect(profile.getSettings(user.id).settings?.unsplash_api_key).toBe('unsplash-secret-key');
}); });
it('AUTH-DB-010c: a key set in the environment comes back as its variable name, not a value (#1881)', () => {
const prev = process.env.PLACES_API_KEY;
process.env.PLACES_API_KEY = 'google-from-env';
try {
const { user } = createAdmin(testDb);
profile.updateApiKeys(user.id, { maps_api_key: 'stored-but-overridden', unsplash_api_key: 'stored-unsplash' });
const settings = profile.getSettings(user.id).settings;
// Neither the stored value nobody searches with nor the operator's own.
expect(settings?.maps_api_key).toBeNull();
expect(settings?.env_keys).toEqual({ maps_api_key: 'PLACES_API_KEY' });
expect(JSON.stringify(settings)).not.toContain('google-from-env');
// A key without a variable reads as before.
expect(settings?.unsplash_api_key).toBe('stored-unsplash');
} finally {
if (prev === undefined) delete process.env.PLACES_API_KEY;
else process.env.PLACES_API_KEY = prev;
}
});
it('AUTH-DB-010d: without a variable env_keys is empty', () => {
const { user } = createAdmin(testDb);
expect(profile.getSettings(user.id).settings?.env_keys).toEqual({});
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1
View File
@@ -157,6 +157,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'مفتاح واجهة برمجة تطبيقات Amap (高德地图)', 'admin.amapKey': 'مفتاح واجهة برمجة تطبيقات Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'للبحث عن الأماكن في البر الرئيسي للصين، حيث لا يمكن الوصول إلى Google وتكون بيانات OpenStreetMap شحيحة. يتطلب مفتاحًا من نوع «Web 服务» (خدمة ويب)، وليس مفتاح JS API. يمكن الحصول عليه من console.amap.com.', 'للبحث عن الأماكن في البر الرئيسي للصين، حيث لا يمكن الوصول إلى Google وتكون بيانات OpenStreetMap شحيحة. يتطلب مفتاحًا من نوع «Web 服务» (خدمة ويب)، وليس مفتاح JS API. يمكن الحصول عليه من console.amap.com.',
'admin.keyFromEnv': 'مُعيَّن عبر {name}',
'admin.placesProvider.title': 'مزوّد البحث عن الأماكن', 'admin.placesProvider.title': 'مزوّد البحث عن الأماكن',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'يجيب فهرس TREK الخاص وOpenStreetMap عن كل بحث. هنا يُختار من يُسأل أيضاً عندما لا يجدان شيئاً: «تلقائي» يفضّل Google عند وجود مفتاح، ثم Amap.', 'يجيب فهرس TREK الخاص وOpenStreetMap عن كل بحث. هنا يُختار من يُسأل أيضاً عندما لا يجدان شيئاً: «تلقائي» يفضّل Google عند وجود مفتاح، ثم Amap.',
+1
View File
@@ -135,6 +135,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Chave de API do Amap (高德地图)', 'admin.amapKey': 'Chave de API do Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Para a busca de lugares na China continental, onde o Google é inacessível e o OpenStreetMap tem poucos dados. Exige uma chave do tipo "Web 服务" (serviço web), não uma chave da API JS. Obtenha em console.amap.com.', 'Para a busca de lugares na China continental, onde o Google é inacessível e o OpenStreetMap tem poucos dados. Exige uma chave do tipo "Web 服务" (serviço web), não uma chave da API JS. Obtenha em console.amap.com.',
'admin.keyFromEnv': 'Definida via {name}',
'admin.placesProvider.title': 'Provedor de busca de lugares', 'admin.placesProvider.title': 'Provedor de busca de lugares',
'admin.placesProvider.subtitle': 'O índice do próprio TREK e o OpenStreetMap respondem a cada busca. Aqui se escolhe quem mais é consultado quando eles não acham nada: Automático prefere o Google se houver chave, depois Amap.', 'admin.placesProvider.subtitle': 'O índice do próprio TREK e o OpenStreetMap respondem a cada busca. Aqui se escolhe quem mais é consultado quando eles não acham nada: Automático prefere o Google se houver chave, depois Amap.',
'admin.placesProvider.auto': 'Automático', 'admin.placesProvider.auto': 'Automático',
+1
View File
@@ -446,6 +446,7 @@ const admin: TranslationStrings = {
'admin.amapKey': "Clau API d'Amap (高德地图)", 'admin.amapKey': "Clau API d'Amap (高德地图)",
'admin.amapKeyHint': 'admin.amapKeyHint':
"Per a la cerca de llocs a la Xina continental, on Google no és accessible i la cobertura d'OpenStreetMap és escassa. Cal una clau «Web 服务» (servei web), no una clau de l'API JS. Se n'obté una a console.amap.com.", "Per a la cerca de llocs a la Xina continental, on Google no és accessible i la cobertura d'OpenStreetMap és escassa. Cal una clau «Web 服务» (servei web), no una clau de l'API JS. Se n'obté una a console.amap.com.",
'admin.keyFromEnv': 'Definida mitjançant {name}',
'admin.placesProvider.title': 'Proveïdor de la cerca de llocs', 'admin.placesProvider.title': 'Proveïdor de la cerca de llocs',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
"L'índex propi de TREK i OpenStreetMap responen cada cerca. Aquí es tria a qui més es consulta quan no troben res: Automàtic prefereix Google si hi ha clau, després Amap.", "L'índex propi de TREK i OpenStreetMap responen cada cerca. Aquí es tria a qui més es consulta quan no troben res: Automàtic prefereix Google si hi ha clau, després Amap.",
+1
View File
@@ -135,6 +135,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'API klíč Amap (高德地图)', 'admin.amapKey': 'API klíč Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Pro hledání míst v kontinentální Číně, kde Google není dostupný a OpenStreetMap má málo dat. Vyžaduje klíč typu „Web 服务" (webová služba), nikoli klíč JS API. Získáte na console.amap.com.', 'Pro hledání míst v kontinentální Číně, kde Google není dostupný a OpenStreetMap má málo dat. Vyžaduje klíč typu „Web 服务" (webová služba), nikoli klíč JS API. Získáte na console.amap.com.',
'admin.keyFromEnv': 'Nastaveno přes {name}',
'admin.placesProvider.title': 'Poskytovatel hledání míst', 'admin.placesProvider.title': 'Poskytovatel hledání míst',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'Vlastní index TREKu a OpenStreetMap odpovídají na každé hledání. Zde se volí, koho se zeptat navíc, když nic nenajdou: Automaticky upřednostní Google, pokud je klíč, pak Amap.', 'Vlastní index TREKu a OpenStreetMap odpovídají na každé hledání. Zde se volí, koho se zeptat navíc, když nic nenajdou: Automaticky upřednostní Google, pokud je klíč, pak Amap.',
+1
View File
@@ -137,6 +137,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Amap (高德地图) API-Key', 'admin.amapKey': 'Amap (高德地图) API-Key',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Für die Ortssuche in Festlandchina, wo Google nicht erreichbar ist und OpenStreetMap kaum Daten hat. Benötigt einen Key vom Typ „Web 服务" (Webdienst), nicht einen JS-API-Key. Erhältlich auf console.amap.com.', 'Für die Ortssuche in Festlandchina, wo Google nicht erreichbar ist und OpenStreetMap kaum Daten hat. Benötigt einen Key vom Typ „Web 服务" (Webdienst), nicht einen JS-API-Key. Erhältlich auf console.amap.com.',
'admin.keyFromEnv': 'Gesetzt über {name}',
'admin.placesProvider.title': 'Anbieter für die Ortssuche', 'admin.placesProvider.title': 'Anbieter für die Ortssuche',
'admin.placesProvider.subtitle': 'Der TREK-Index und OpenStreetMap beantworten jede Suche. Hier wird gewählt, wer zusätzlich gefragt wird, wenn die beiden nichts finden: Automatisch bevorzugt Google, wenn ein Schlüssel da ist, danach Amap.', 'admin.placesProvider.subtitle': 'Der TREK-Index und OpenStreetMap beantworten jede Suche. Hier wird gewählt, wer zusätzlich gefragt wird, wenn die beiden nichts finden: Automatisch bevorzugt Google, wenn ein Schlüssel da ist, danach Amap.',
'admin.placesProvider.auto': 'Automatisch', 'admin.placesProvider.auto': 'Automatisch',
+1
View File
@@ -177,6 +177,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Amap (高德地图) API Key', 'admin.amapKey': 'Amap (高德地图) API Key',
'admin.amapKeyHint': 'admin.amapKeyHint':
'For place search inside mainland China, where Google is unreachable and OpenStreetMap coverage is thin. Needs a "Web 服务" (web service) key, not a JS API key. Get one at console.amap.com.', 'For place search inside mainland China, where Google is unreachable and OpenStreetMap coverage is thin. Needs a "Web 服务" (web service) key, not a JS API key. Get one at console.amap.com.',
'admin.keyFromEnv': 'Set via {name}',
'admin.placesProvider.title': 'Place search provider', 'admin.placesProvider.title': 'Place search provider',
'admin.placesProvider.subtitle': "TREK's own index and OpenStreetMap answer every search. This picks who else is asked when they find nothing: Automatic prefers Google where a key exists, then Amap.", 'admin.placesProvider.subtitle': "TREK's own index and OpenStreetMap answer every search. This picks who else is asked when they find nothing: Automatic prefers Google where a key exists, then Amap.",
'admin.placesProvider.auto': 'Automatic', 'admin.placesProvider.auto': 'Automatic',
+1
View File
@@ -137,6 +137,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Clave de API de Amap (高德地图)', 'admin.amapKey': 'Clave de API de Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Para la búsqueda de lugares en China continental, donde Google no es accesible y OpenStreetMap apenas tiene datos. Requiere una clave de tipo «Web 服务» (servicio web), no una clave de la API de JS. Consíguela en console.amap.com.', 'Para la búsqueda de lugares en China continental, donde Google no es accesible y OpenStreetMap apenas tiene datos. Requiere una clave de tipo «Web 服务» (servicio web), no una clave de la API de JS. Consíguela en console.amap.com.',
'admin.keyFromEnv': 'Definida mediante {name}',
'admin.placesProvider.title': 'Proveedor de búsqueda de lugares', 'admin.placesProvider.title': 'Proveedor de búsqueda de lugares',
'admin.placesProvider.subtitle': 'El índice propio de TREK y OpenStreetMap responden a cada búsqueda. Aquí se elige a quién más se consulta cuando no encuentran nada: Automático prefiere Google si hay clave, luego Amap.', 'admin.placesProvider.subtitle': 'El índice propio de TREK y OpenStreetMap responden a cada búsqueda. Aquí se elige a quién más se consulta cuando no encuentran nada: Automático prefiere Google si hay clave, luego Amap.',
'admin.placesProvider.auto': 'Automático', 'admin.placesProvider.auto': 'Automático',
+1
View File
@@ -136,6 +136,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Clé API Amap (高德地图)', 'admin.amapKey': 'Clé API Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Pour la recherche de lieux en Chine continentale, où Google est inaccessible et où OpenStreetMap contient très peu de données. Nécessite une clé de type « Web 服务 » (service web), pas une clé d’API JS. À obtenir sur console.amap.com.', 'Pour la recherche de lieux en Chine continentale, où Google est inaccessible et où OpenStreetMap contient très peu de données. Nécessite une clé de type « Web 服务 » (service web), pas une clé d’API JS. À obtenir sur console.amap.com.',
'admin.keyFromEnv': 'Défini via {name}',
'admin.placesProvider.title': 'Fournisseur de recherche de lieux', 'admin.placesProvider.title': 'Fournisseur de recherche de lieux',
'admin.placesProvider.subtitle': "L'index propre de TREK et OpenStreetMap répondent à chaque recherche. Ceci choisit qui est interrogé en plus quand ils ne trouvent rien : Automatique préfère Google si une clé existe, puis Amap.", 'admin.placesProvider.subtitle': "L'index propre de TREK et OpenStreetMap répondent à chaque recherche. Ceci choisit qui est interrogé en plus quand ils ne trouvent rien : Automatique préfère Google si une clé existe, puis Amap.",
'admin.placesProvider.auto': 'Automatique', 'admin.placesProvider.auto': 'Automatique',
+1
View File
@@ -182,6 +182,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Κλειδί API του Amap (高德地图)', 'admin.amapKey': 'Κλειδί API του Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Για αναζήτηση τοποθεσιών στην ηπειρωτική Κίνα, όπου το Google δεν είναι προσβάσιμο και το OpenStreetMap έχει ελάχιστα δεδομένα. Χρειάζεται κλειδί τύπου «Web 服务» (υπηρεσία web), όχι κλειδί JS API. Διαθέσιμο στο console.amap.com.', 'Για αναζήτηση τοποθεσιών στην ηπειρωτική Κίνα, όπου το Google δεν είναι προσβάσιμο και το OpenStreetMap έχει ελάχιστα δεδομένα. Χρειάζεται κλειδί τύπου «Web 服务» (υπηρεσία web), όχι κλειδί JS API. Διαθέσιμο στο console.amap.com.',
'admin.keyFromEnv': 'Ορίζεται μέσω {name}',
'admin.placesProvider.title': 'Πάροχος αναζήτησης τοποθεσιών', 'admin.placesProvider.title': 'Πάροχος αναζήτησης τοποθεσιών',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'Το δικό του ευρετήριο του TREK και το OpenStreetMap απαντούν σε κάθε αναζήτηση. Εδώ επιλέγεται ποιος άλλος ρωτιέται όταν δεν βρίσκουν τίποτα: το Αυτόματο προτιμά τη Google αν υπάρχει κλειδί, μετά το Amap.', 'Το δικό του ευρετήριο του TREK και το OpenStreetMap απαντούν σε κάθε αναζήτηση. Εδώ επιλέγεται ποιος άλλος ρωτιέται όταν δεν βρίσκουν τίποτα: το Αυτόματο προτιμά τη Google αν υπάρχει κλειδί, μετά το Amap.',
+1
View File
@@ -136,6 +136,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Amap (高德地图) API-kulcs', 'admin.amapKey': 'Amap (高德地图) API-kulcs',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Helykereséshez Kína szárazföldi részén, ahol a Google nem érhető el, az OpenStreetMap adatai pedig hiányosak. „Web 服务" (webszolgáltatás) típusú kulcs kell hozzá, nem JS API-kulcs. A console.amap.com oldalon kérhető.', 'Helykereséshez Kína szárazföldi részén, ahol a Google nem érhető el, az OpenStreetMap adatai pedig hiányosak. „Web 服务" (webszolgáltatás) típusú kulcs kell hozzá, nem JS API-kulcs. A console.amap.com oldalon kérhető.',
'admin.keyFromEnv': 'Beállítva: {name}',
'admin.placesProvider.title': 'Helykeresési szolgáltató', 'admin.placesProvider.title': 'Helykeresési szolgáltató',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'A TREK saját indexe és az OpenStreetMap minden keresésre válaszol. Itt választod ki, kit kérdezünk még, ha ők nem találnak semmit: az Automatikus a Google-t részesíti előnyben, ha van kulcs, utána az Amapot.', 'A TREK saját indexe és az OpenStreetMap minden keresésre válaszol. Itt választod ki, kit kérdezünk még, ha ők nem találnak semmit: az Automatikus a Google-t részesíti előnyben, ha van kulcs, utána az Amapot.',
+1
View File
@@ -178,6 +178,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Kunci API Amap (高德地图)', 'admin.amapKey': 'Kunci API Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Untuk pencarian tempat di Tiongkok daratan, tempat Google tidak dapat diakses dan data OpenStreetMap sangat sedikit. Perlu kunci jenis "Web 服务" (layanan web), bukan kunci JS API. Dapatkan di console.amap.com.', 'Untuk pencarian tempat di Tiongkok daratan, tempat Google tidak dapat diakses dan data OpenStreetMap sangat sedikit. Perlu kunci jenis "Web 服务" (layanan web), bukan kunci JS API. Dapatkan di console.amap.com.',
'admin.keyFromEnv': 'Diatur lewat {name}',
'admin.placesProvider.title': 'Penyedia pencarian tempat', 'admin.placesProvider.title': 'Penyedia pencarian tempat',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'Indeks milik TREK dan OpenStreetMap menjawab setiap pencarian. Di sini dipilih siapa lagi yang ditanya bila keduanya tidak menemukan apa pun: Otomatis memilih Google bila ada kunci, lalu Amap.', 'Indeks milik TREK dan OpenStreetMap menjawab setiap pencarian. Di sini dipilih siapa lagi yang ditanya bila keduanya tidak menemukan apa pun: Otomatis memilih Google bila ada kunci, lalu Amap.',
+1
View File
@@ -136,6 +136,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Chiave API Amap (高德地图)', 'admin.amapKey': 'Chiave API Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Per la ricerca di luoghi nella Cina continentale, dove Google non è raggiungibile e OpenStreetMap ha pochi dati. Serve una chiave di tipo «Web 服务» (servizio web), non una chiave API JS. Si ottiene su console.amap.com.', 'Per la ricerca di luoghi nella Cina continentale, dove Google non è raggiungibile e OpenStreetMap ha pochi dati. Serve una chiave di tipo «Web 服务» (servizio web), non una chiave API JS. Si ottiene su console.amap.com.',
'admin.keyFromEnv': 'Impostata tramite {name}',
'admin.placesProvider.title': 'Provider per la ricerca di luoghi', 'admin.placesProvider.title': 'Provider per la ricerca di luoghi',
'admin.placesProvider.subtitle': "L'indice di TREK e OpenStreetMap rispondono a ogni ricerca. Qui si sceglie chi altro viene interrogato quando non trovano nulla: Automatico preferisce Google se esiste una chiave, poi Amap.", 'admin.placesProvider.subtitle': "L'indice di TREK e OpenStreetMap rispondono a ogni ricerca. Qui si sceglie chi altro viene interrogato quando non trovano nulla: Automatico preferisce Google se esiste una chiave, poi Amap.",
'admin.placesProvider.auto': 'Automatico', 'admin.placesProvider.auto': 'Automatico',
+1
View File
@@ -170,6 +170,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Amap(高德地图)API キー', 'admin.amapKey': 'Amap(高德地图)API キー',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Google に接続できず OpenStreetMap のデータも少ない中国本土での地点検索用です。JS API キーではなく「Web 服务」(Web サービス)タイプのキーが必要です。console.amap.com で取得できます。', 'Google に接続できず OpenStreetMap のデータも少ない中国本土での地点検索用です。JS API キーではなく「Web 服务」(Web サービス)タイプのキーが必要です。console.amap.com で取得できます。',
'admin.keyFromEnv': '{name} で設定済み',
'admin.placesProvider.title': '地点検索のプロバイダー', 'admin.placesProvider.title': '地点検索のプロバイダー',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'TREK 自身の索引と OpenStreetMap がすべての検索に答えます。ここでは、それらが何も見つけられなかったときに誰に尋ねるかを選びます。自動はキーがあれば Google を、次に Amap を選びます。', 'TREK 自身の索引と OpenStreetMap がすべての検索に答えます。ここでは、それらが何も見つけられなかったときに誰に尋ねるかを選びます。自動はキーがあれば Google を、次に Amap を選びます。',
+1
View File
@@ -175,6 +175,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Amap(高德地图) API 키', 'admin.amapKey': 'Amap(高德地图) API 키',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Google에 접속할 수 없고 OpenStreetMap 데이터도 부족한 중국 본토에서 장소를 검색할 때 사용합니다. JS API 키가 아니라 "Web 服务"(웹 서비스) 유형의 키가 필요합니다. console.amap.com에서 발급받을 수 있습니다.', 'Google에 접속할 수 없고 OpenStreetMap 데이터도 부족한 중국 본토에서 장소를 검색할 때 사용합니다. JS API 키가 아니라 "Web 服务"(웹 서비스) 유형의 키가 필요합니다. console.amap.com에서 발급받을 수 있습니다.',
'admin.keyFromEnv': '{name}(으)로 설정됨',
'admin.placesProvider.title': '장소 검색 제공자', 'admin.placesProvider.title': '장소 검색 제공자',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'TREK 자체 색인과 OpenStreetMap이 모든 검색에 답합니다. 여기서는 둘이 아무것도 찾지 못했을 때 누구에게 더 물을지 고릅니다. 자동은 키가 있으면 Google을, 그다음 Amap을 씁니다.', 'TREK 자체 색인과 OpenStreetMap이 모든 검색에 답합니다. 여기서는 둘이 아무것도 찾지 못했을 때 누구에게 더 물을지 고릅니다. 자동은 키가 있으면 Google을, 그다음 Amap을 씁니다.',
+1
View File
@@ -137,6 +137,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Amap (高德地图) API-sleutel', 'admin.amapKey': 'Amap (高德地图) API-sleutel',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Voor het zoeken naar plaatsen in het Chinese vasteland, waar Google onbereikbaar is en OpenStreetMap weinig gegevens heeft. Vereist een sleutel van het type ‘Web 服务’ (webservice), geen JS API-sleutel. Verkrijgbaar op console.amap.com.', 'Voor het zoeken naar plaatsen in het Chinese vasteland, waar Google onbereikbaar is en OpenStreetMap weinig gegevens heeft. Vereist een sleutel van het type ‘Web 服务’ (webservice), geen JS API-sleutel. Verkrijgbaar op console.amap.com.',
'admin.keyFromEnv': 'Ingesteld via {name}',
'admin.placesProvider.title': 'Provider voor plaatszoeken', 'admin.placesProvider.title': 'Provider voor plaatszoeken',
'admin.placesProvider.subtitle': 'De eigen index van TREK en OpenStreetMap beantwoorden elke zoekopdracht. Hier kies je wie er nog meer wordt gevraagd als zij niets vinden: Automatisch geeft de voorkeur aan Google als er een sleutel is, daarna Amap.', 'admin.placesProvider.subtitle': 'De eigen index van TREK en OpenStreetMap beantwoorden elke zoekopdracht. Hier kies je wie er nog meer wordt gevraagd als zij niets vinden: Automatisch geeft de voorkeur aan Google als er een sleutel is, daarna Amap.',
'admin.placesProvider.auto': 'Automatisch', 'admin.placesProvider.auto': 'Automatisch',
+1
View File
@@ -128,6 +128,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Klucz API Amap (高德地图)', 'admin.amapKey': 'Klucz API Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Do wyszukiwania miejsc w Chinach kontynentalnych, gdzie Google jest nieosiągalny, a OpenStreetMap ma niewiele danych. Wymaga klucza typu „Web 服务" (usługa sieciowa), a nie klucza JS API. Można go uzyskać na console.amap.com.', 'Do wyszukiwania miejsc w Chinach kontynentalnych, gdzie Google jest nieosiągalny, a OpenStreetMap ma niewiele danych. Wymaga klucza typu „Web 服务" (usługa sieciowa), a nie klucza JS API. Można go uzyskać na console.amap.com.',
'admin.keyFromEnv': 'Ustawiono przez {name}',
'admin.placesProvider.title': 'Dostawca wyszukiwania miejsc', 'admin.placesProvider.title': 'Dostawca wyszukiwania miejsc',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'Własny indeks TREK i OpenStreetMap odpowiadają na każde wyszukiwanie. Tutaj wybierasz, kogo zapytać dodatkowo, gdy nic nie znajdą: Automatycznie preferuje Google, jeśli jest klucz, potem Amap.', 'Własny indeks TREK i OpenStreetMap odpowiadają na każde wyszukiwanie. Tutaj wybierasz, kogo zapytać dodatkowo, gdy nic nie znajdą: Automatycznie preferuje Google, jeśli jest klucz, potem Amap.',
+1
View File
@@ -137,6 +137,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'API-ключ Amap (高德地图)', 'admin.amapKey': 'API-ключ Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Для поиска мест в континентальном Китае, где Google недоступен, а данных OpenStreetMap мало. Нужен ключ типа «Web 服务» (веб-сервис), а не ключ JS API. Получить можно на console.amap.com.', 'Для поиска мест в континентальном Китае, где Google недоступен, а данных OpenStreetMap мало. Нужен ключ типа «Web 服务» (веб-сервис), а не ключ JS API. Получить можно на console.amap.com.',
'admin.keyFromEnv': 'Задан через {name}',
'admin.placesProvider.title': 'Провайдер поиска мест', 'admin.placesProvider.title': 'Провайдер поиска мест',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'Собственный индекс TREK и OpenStreetMap отвечают на каждый поиск. Здесь выбирается, кого спросить дополнительно, если они ничего не нашли: «Автоматически» предпочитает Google при наличии ключа, затем Amap.', 'Собственный индекс TREK и OpenStreetMap отвечают на каждый поиск. Здесь выбирается, кого спросить дополнительно, если они ничего не нашли: «Автоматически» предпочитает Google при наличии ключа, затем Amap.',
+1
View File
@@ -179,6 +179,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Amap (高德地图) API-nyckel', 'admin.amapKey': 'Amap (高德地图) API-nyckel',
'admin.amapKeyHint': 'admin.amapKeyHint':
'För platssökning i Fastlandskina, där Google inte går att nå och OpenStreetMap har tunn täckning. Kräver en "Web 服务"-nyckel (webbtjänst), inte en JS API-nyckel. Skaffa en på console.amap.com.', 'För platssökning i Fastlandskina, där Google inte går att nå och OpenStreetMap har tunn täckning. Kräver en "Web 服务"-nyckel (webbtjänst), inte en JS API-nyckel. Skaffa en på console.amap.com.',
'admin.keyFromEnv': 'Satt via {name}',
'admin.placesProvider.title': 'Leverantör för platssökning', 'admin.placesProvider.title': 'Leverantör för platssökning',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'TREK:s egen indexering och OpenStreetMap svarar på varje sökning. Här väljs vem som frågas därutöver när de inte hittar något: Automatiskt föredrar Google om en nyckel finns, sedan Amap.', 'TREK:s egen indexering och OpenStreetMap svarar på varje sökning. Här väljs vem som frågas därutöver när de inte hittar något: Automatiskt föredrar Google om en nyckel finns, sedan Amap.',
+1
View File
@@ -179,6 +179,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Amap (高德地图) API Anahtarı', 'admin.amapKey': 'Amap (高德地图) API Anahtarı',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Google’ın erişilemediği ve OpenStreetMap verisinin çok az olduğu Çin anakarasında yer arama için. «Web 服务» (web servisi) türünde bir anahtar gerekir, JS API anahtarı değil. console.amap.com adresinden alınır.', 'Google’ın erişilemediği ve OpenStreetMap verisinin çok az olduğu Çin anakarasında yer arama için. «Web 服务» (web servisi) türünde bir anahtar gerekir, JS API anahtarı değil. console.amap.com adresinden alınır.',
'admin.keyFromEnv': '{name} ile ayarlandı',
'admin.placesProvider.title': 'Yer arama sağlayıcısı', 'admin.placesProvider.title': 'Yer arama sağlayıcısı',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
"TREK'in kendi dizini ve OpenStreetMap her aramayı yanıtlar. Burada, onlar bir şey bulamadığında başka kime sorulacağı seçilir: Otomatik, anahtar varsa Google'ı, sonra Amap'i tercih eder.", "TREK'in kendi dizini ve OpenStreetMap her aramayı yanıtlar. Burada, onlar bir şey bulamadığında başka kime sorulacağı seçilir: Otomatik, anahtar varsa Google'ı, sonra Amap'i tercih eder.",
+1
View File
@@ -137,6 +137,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'API-ключ Amap (高德地图)', 'admin.amapKey': 'API-ключ Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Для пошуку місць у континентальному Китаї, де Google недоступний, а даних OpenStreetMap обмаль. Потрібен ключ типу «Web 服务» (вебсервіс), а не ключ JS API. Отримати можна на console.amap.com.', 'Для пошуку місць у континентальному Китаї, де Google недоступний, а даних OpenStreetMap обмаль. Потрібен ключ типу «Web 服务» (вебсервіс), а не ключ JS API. Отримати можна на console.amap.com.',
'admin.keyFromEnv': 'Задано через {name}',
'admin.placesProvider.title': 'Постачальник пошуку місць', 'admin.placesProvider.title': 'Постачальник пошуку місць',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'Власний індекс TREK і OpenStreetMap відповідають на кожен пошук. Тут обирається, кого запитати додатково, якщо вони нічого не знайшли: «Автоматично» надає перевагу Google за наявності ключа, потім Amap.', 'Власний індекс TREK і OpenStreetMap відповідають на кожен пошук. Тут обирається, кого запитати додатково, якщо вони нічого не знайшли: «Автоматично» надає перевагу Google за наявності ключа, потім Amap.',
+1
View File
@@ -178,6 +178,7 @@ const admin: TranslationStrings = {
'admin.amapKey': 'Khóa API Amap (高德地图)', 'admin.amapKey': 'Khóa API Amap (高德地图)',
'admin.amapKeyHint': 'admin.amapKeyHint':
'Dùng cho tìm kiếm địa điểm tại Trung Quốc đại lục, nơi không truy cập được Google và dữ liệu OpenStreetMap còn thưa. Cần khóa loại "Web 服务" (dịch vụ web), không phải khóa JS API. Lấy khóa tại console.amap.com.', 'Dùng cho tìm kiếm địa điểm tại Trung Quốc đại lục, nơi không truy cập được Google và dữ liệu OpenStreetMap còn thưa. Cần khóa loại "Web 服务" (dịch vụ web), không phải khóa JS API. Lấy khóa tại console.amap.com.',
'admin.keyFromEnv': 'Đặt qua {name}',
'admin.placesProvider.title': 'Nhà cung cấp tìm kiếm địa điểm', 'admin.placesProvider.title': 'Nhà cung cấp tìm kiếm địa điểm',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'Chỉ mục riêng của TREK và OpenStreetMap trả lời mọi tìm kiếm. Ở đây chọn ai được hỏi thêm khi chúng không tìm thấy gì: Tự động ưu tiên Google nếu có khóa, rồi đến Amap.', 'Chỉ mục riêng của TREK và OpenStreetMap trả lời mọi tìm kiếm. Ở đây chọn ai được hỏi thêm khi chúng không tìm thấy gì: Tự động ưu tiên Google nếu có khóa, rồi đến Amap.',
+1
View File
@@ -174,6 +174,7 @@ const admin: TranslationStrings = {
'admin.amapKey': '高德地圖 API Key', 'admin.amapKey': '高德地圖 API Key',
'admin.amapKeyHint': 'admin.amapKeyHint':
'用於中國大陸境內的地點搜尋(Google 無法存取,OpenStreetMap 內容也很少)。需要「Web 服務」類型的 Key,而非 JS API Key。在 console.amap.com 申請。', '用於中國大陸境內的地點搜尋(Google 無法存取,OpenStreetMap 內容也很少)。需要「Web 服務」類型的 Key,而非 JS API Key。在 console.amap.com 申請。',
'admin.keyFromEnv': '已透過 {name} 設定',
'admin.placesProvider.title': '地點搜尋來源', 'admin.placesProvider.title': '地點搜尋來源',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'TREK 自有索引和 OpenStreetMap 會回應每一次搜尋。這裡選擇的是它們一無所獲時還問誰:自動在有金鑰時優先 Google,其次 Amap。', 'TREK 自有索引和 OpenStreetMap 會回應每一次搜尋。這裡選擇的是它們一無所獲時還問誰:自動在有金鑰時優先 Google,其次 Amap。',
+1
View File
@@ -135,6 +135,7 @@ const admin: TranslationStrings = {
'admin.amapKey': '高德地图 API Key', 'admin.amapKey': '高德地图 API Key',
'admin.amapKeyHint': 'admin.amapKeyHint':
'用于中国大陆境内的地点搜索(Google 无法访问,OpenStreetMap 内容也很少)。需要「Web 服务」类型的 Key,不是 JS API Key。在 console.amap.com 申请。', '用于中国大陆境内的地点搜索(Google 无法访问,OpenStreetMap 内容也很少)。需要「Web 服务」类型的 Key,不是 JS API Key。在 console.amap.com 申请。',
'admin.keyFromEnv': '已通过 {name} 设置',
'admin.placesProvider.title': '地点搜索源', 'admin.placesProvider.title': '地点搜索源',
'admin.placesProvider.subtitle': 'admin.placesProvider.subtitle':
'TREK 自有索引和 OpenStreetMap 会回应每一次搜索。这里选择的是它们一无所获时还问谁:自动在有密钥时优先 Google,其次 Amap。', 'TREK 自有索引和 OpenStreetMap 会回应每一次搜索。这里选择的是它们一无所获时还问谁:自动在有密钥时优先 Google,其次 Amap。',
+21 -2
View File
@@ -311,7 +311,7 @@ Some hosting environments — commonly VPS and datacenter IP ranges (and many Ku
**Two ways to configure it** — pick one; the env var wins if both are present: **Two ways to configure it** — pick one; the env var wins if both are present:
1. **Environment variable** (this page) — instance-wide, ideal for Docker/Helm/Unraid where you already manage config as env. 1. **Environment variable** (this page) — instance-wide, ideal for Docker/Helm/Unraid where you already manage config as env. The **Unsplash API Key** field in the admin panel is then read-only and names the variable.
2. **Admin → Settings → API Keys** — paste the key into the **Unsplash API Key** field. Stored encrypted at rest and used as a fallback for every user when no env var is set. This is the better option if you'd rather not restart the container to change it. 2. **Admin → Settings → API Keys** — paste the key into the **Unsplash API Key** field. Stored encrypted at rest and used as a fallback for every user when no env var is set. This is the better option if you'd rather not restart the container to change it.
To get a key: create a free account at [unsplash.com/developers](https://unsplash.com/developers), register a new application, and copy its **Access Key** (not the Secret Key). The Unsplash free tier (demo) allows 50 requests/hour, which is ample for cover search. To get a key: create a free account at [unsplash.com/developers](https://unsplash.com/developers), register a new application, and copy its **Access Key** (not the Secret Key). The Unsplash free tier (demo) allows 50 requests/hour, which is ample for cover search.
@@ -331,6 +331,25 @@ On Helm both go under `env:` in `values.yaml`. The chart passes `TREK_PLACES_ENA
--- ---
## Place Search (Google Places)
A Google Maps API key gives place search Google as the keyed provider beside the TREK Places index and OpenStreetMap, and switches on Google photos, place details and the Google transit backend. What the key is used for, and the switches that limit it, are on [Places and Search](Places-and-Search#with-a-google-maps-api-key).
| Variable | Description | Default |
|------------------|-------------|---------|
| `PLACES_API_KEY` | Google Maps API key with the **Places API (New)** enabled. When set, it takes priority over the key configured in **Admin → Settings → API Keys** and is used for every member of the instance. | unset |
**Two ways to configure it**, pick one; the env var wins if both are present:
1. **Environment variable** (this page): instance-wide, ideal for Docker/Helm/Unraid where you already manage config as env. In the chart it is a credential and goes under `secretEnv:`, not `env:`.
2. **Admin → Settings → API Keys**: paste it into the **Google Maps API Key** field. Stored encrypted at rest.
While the variable is set, the **Google Maps API Key** field in the admin panel is read-only and names `PLACES_API_KEY` instead of showing a value. **Test** beside it still works and checks the key from the environment. The same goes for `UNSPLASH_ACCESS_KEY` and `AMAP_API_KEY` and their fields. The value itself never reaches the browser.
If you restrict the key to **HTTP referrers** in Google Cloud Console, set `APP_URL` as well: TREK sends it as the `Referer` header on every Google request, and without it Google rejects them.
---
## Place Search (Amap / 高德地图) ## Place Search (Amap / 高德地图)
Google Places is unreachable from most networks inside mainland China, and OpenStreetMap's coverage of Chinese POIs Google Places is unreachable from most networks inside mainland China, and OpenStreetMap's coverage of Chinese POIs
@@ -351,7 +370,7 @@ type **Web 服务**. A **Web 端 (JS API)** key is a different kind of credentia
**Two ways to configure it**, pick one; the env var wins if both are present: **Two ways to configure it**, pick one; the env var wins if both are present:
1. **Environment variable** (this page): instance-wide, ideal for Docker/Helm where you already manage config as env. In the chart the key and the secret are credentials and go under `secretEnv:`, not `env:`; only `AMAP_API_BASE` is a plain `env:` value. 1. **Environment variable** (this page): instance-wide, ideal for Docker/Helm where you already manage config as env. In the chart the key and the secret are credentials and go under `secretEnv:`, not `env:`; only `AMAP_API_BASE` is a plain `env:` value. The **Amap (高德地图) API Key** field in the admin panel is then read-only and names the variable.
2. **Admin → Settings → API Keys**: paste it into the **Amap (高德地图) API Key** field. Stored encrypted at rest. 2. **Admin → Settings → API Keys**: paste it into the **Amap (高德地图) API Key** field. Stored encrypted at rest.
Setting a key is not enough on its own: **Admin → Settings → API Keys → Place search provider** decides which keyed Setting a key is not enough on its own: **Admin → Settings → API Keys → Place search provider** decides which keyed
+1
View File
@@ -141,6 +141,7 @@ secretEnv:
ADMIN_PASSWORD: "" # initial admin password (first boot only) ADMIN_PASSWORD: "" # initial admin password (first boot only)
OIDC_CLIENT_SECRET: "" # set if using OIDC OIDC_CLIENT_SECRET: "" # set if using OIDC
UNSPLASH_ACCESS_KEY: "" # optional; free key from unsplash.com/developers UNSPLASH_ACCESS_KEY: "" # optional; free key from unsplash.com/developers
PLACES_API_KEY: "" # optional; Google Maps API key, wins over the one in Admin > Settings
``` ```
Alternatively, use `generateEncryptionKey: true` to let the chart generate and manage the encryption key, or point `existingSecret` / `existingSecretKey` at an existing Kubernetes Secret. Alternatively, use `generateEncryptionKey: true` to let the chart generate and manage the encryption key, or point `existingSecret` / `existingSecretKey` at an existing Kubernetes Secret.
+1 -1
View File
@@ -51,7 +51,7 @@ The place form on the desktop and the search sheet on the phone use the same hin
### With a Google Maps API key ### With a Google Maps API key
> **Admin:** The Google Maps API key is instance-wide, set in **Admin → Settings → API Keys → Google Maps API Key**. It is stored encrypted at rest and used for every member of the instance. > **Admin:** The Google Maps API key is instance-wide, set in **Admin → Settings → API Keys → Google Maps API Key**. It is stored encrypted at rest and used for every member of the instance. It can also come from the `PLACES_API_KEY` environment variable, which wins over the field and leaves it read-only; see [Environment Variables](Environment-Variables#place-search-google-places).
A key does not buy a different search by itself. Google fills the slot that answers once the TREK API and OpenStreetMap both have nothing, and it adds what only a commercial provider has: ratings and photos. No open dataset carries either of those for ordinary businesses. A place found through Google keeps its Google id, so its details, rating and photos keep coming from Google. A key does not buy a different search by itself. Google fills the slot that answers once the TREK API and OpenStreetMap both have nothing, and it adds what only a commercial provider has: ratings and photos. No open dataset carries either of those for ordinary businesses. A place found through Google keeps its Google id, so its details, rating and photos keep coming from Google.