Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';

import { BasemapService } from './basemap.service';
import { SettingsService } from './settings.service';

describe('BasemapService', () => {
let service: BasemapService;
const siteSettings = signal<Record<string, string>>({});

beforeEach(() => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
{
provide: SettingsService,
useValue: { siteSettings },
},
],
});
service = TestBed.inject(BasemapService);
siteSettings.set({});
});

describe('key substitution', () => {
it('substitutes the configured key into the default CARTO template', () => {
siteSettings.set({ basemap_key: 'abc123' });
expect(service.tileUrl()).toBe('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png?key=abc123');
});

it('URL-encodes a key containing reserved characters', () => {
siteSettings.set({ basemap_key: 'a+b/c=d&e' });
expect(service.tileUrl()).toContain('key=a%2Bb%2Fc%3Dd%26e');
});

it('trims surrounding whitespace, which is what a paste into an admin field leaves behind', () => {
siteSettings.set({ basemap_key: ' abc123\n' });
expect(service.tileUrl()).toContain('key=abc123');
});

it('leaves no {key} placeholder behind when no key is set', () => {
// Leaflet's template helper throws on a placeholder it has no value for, so an unsubstituted
// {key} would break the map outright rather than merely watermark it.
expect(service.tileUrl()).not.toContain('{key}');
});

it('leaves Leaflet its own placeholders', () => {
siteSettings.set({ basemap_key: 'abc123' });
const url = service.tileUrl();
for (const placeholder of ['{s}', '{z}', '{x}', '{y}', '{r}']) {
expect(url).toContain(placeholder);
}
});
});

describe('missingKey', () => {
it('is true when the template wants a key and none is configured', () => {
expect(service.missingKey()).toBe(true);
});

it('is false once a key is set', () => {
siteSettings.set({ basemap_key: 'abc123' });
expect(service.missingKey()).toBe(false);
});

it('is false for a keyless provider, which never wanted a key', () => {
siteSettings.set({ basemap_url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' });
expect(service.missingKey()).toBe(false);
});

it('is true for a whitespace-only key, which is the same as none', () => {
siteSettings.set({ basemap_key: ' ' });
expect(service.missingKey()).toBe(true);
});
});

describe('overrides', () => {
it('uses a configured tile URL in place of the CARTO default', () => {
siteSettings.set({ basemap_key: 'k', basemap_url: 'https://tiles.example/{z}/{x}/{y}.png?token={key}' });
expect(service.tileUrl()).toBe('https://tiles.example/{z}/{x}/{y}.png?token=k');
});

it('applies the configured attribution to the layer', () => {
siteSettings.set({ basemap_attribution: '&copy; Example' });
expect(service.createLayer().options.attribution).toBe('&copy; Example');
});

it('defaults to CARTO and OSM attribution, which three call sites were omitting entirely', () => {
expect(service.createLayer().options.attribution).toContain('carto.com');
expect(service.createLayer().options.attribution).toContain('openstreetmap.org');
});

it('honours a maxZoom override so the overview map keeps its cap of 18', () => {
expect(service.createLayer({ maxZoom: 18 }).options.maxZoom).toBe(18);
expect(service.createLayer().options.maxZoom).toBe(19);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Injectable, computed, inject } from '@angular/core';
import * as L from 'leaflet';

import { SettingsService } from './settings.service';

/**
* CARTO's basemaps now require a key. An unkeyed request still answers 200 and still returns usable
* tiles, so the only signal is an "API KEY REQUIRED" watermark drawn into the image itself: nothing
* logs, no health check notices, and the maps look broken only to whoever is looking at them. See #842.
*
* `{key}` is substituted here rather than handed to Leaflet, because Leaflet's template helper throws
* on a placeholder it has no value for, and because the encoding is ours to get right.
*/
const DEFAULT_URL = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png?key={key}';

const DEFAULT_ATTRIBUTION =
'&copy; <a href="https://carto.com/">CARTO</a> &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>';

@Injectable({ providedIn: 'root' })
export class BasemapService {
private readonly settings = inject(SettingsService);

private readonly attribution = computed(() => this.settings.siteSettings()['basemap_attribution'] || DEFAULT_ATTRIBUTION);

private readonly key = computed(() => (this.settings.siteSettings()['basemap_key'] || '').trim());

private readonly urlTemplate = computed(() => this.settings.siteSettings()['basemap_url'] || DEFAULT_URL);

/**
* True when the configured tile URL asks for a key and no key is set. A caller should say so out
* loud: the tiles will render, watermarked, and look like a styling bug rather than a missing
* setting. An operator who has pointed `basemap_url` at a keyless provider gets false, not a
* warning about a key their URL never wanted.
*/
readonly missingKey = computed(() => this.urlTemplate().includes('{key}') && this.key() === '');

/**
* The single tile layer every map on the site uses.
*
* `maxZoom` is overridable because the five call sites this replaced had already drifted apart, and
* one of them caps at 18. Three carried no attribution at all, which centralising fixes on its own.
*/
createLayer(options?: { maxZoom?: number }): L.TileLayer {
return L.tileLayer(this.tileUrl(), {
attribution: this.attribution(),
maxZoom: options?.maxZoom ?? 19,
subdomains: 'abcd',
});
}

/** Exposed for tests and for anything that needs the URL without a Leaflet map to attach it to. */
tileUrl(): string {
return this.urlTemplate().replace('{key}', encodeURIComponent(this.key()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,13 @@ <h1>{{ 'ADMIN.SETTINGS_TITLE' | translate }}</h1>
}
</div>

@if (group.labelKey === 'ADMIN_SETTINGS.GROUP_MAPS' && basemap.missingKey()) {
<div class="config-hint warning basemap-key-warning">
<mat-icon>info_outline</mat-icon>
<span>{{ 'ADMIN_SETTINGS.BASEMAP_KEY_MISSING' | translate }}</span>
</div>
}

@if (group.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && telegramConfig()) {
@if (!telegramConfig()!.enabled) {
<div class="config-hint warning">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';

import { DiscordServerConfig, OidcServerConfig, PwebSetting, SiteSetting, TelegramServerConfig } from '../../core/models';
import { BasemapService } from '../../core/services/basemap.service';
import { I18nService } from '../../core/services/i18n.service';
import { SettingsService } from '../../core/services/settings.service';
import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component';
Expand Down Expand Up @@ -290,6 +291,31 @@ export const SETTING_GROUPS: SettingGroup[] = [
},
],
},
{
color: '#2e7d32',
icon: 'map',
labelKey: 'ADMIN_SETTINGS.GROUP_MAPS',
settings: [
{
descriptionKey: 'ADMIN_SETTINGS.BASEMAP_KEY_DESC',
key: 'basemap_key',
labelKey: 'ADMIN_SETTINGS.BASEMAP_KEY_LABEL',
type: 'text',
},
{
descriptionKey: 'ADMIN_SETTINGS.BASEMAP_URL_DESC',
key: 'basemap_url',
labelKey: 'ADMIN_SETTINGS.BASEMAP_URL_LABEL',
type: 'text',
},
{
descriptionKey: 'ADMIN_SETTINGS.BASEMAP_ATTRIBUTION_DESC',
key: 'basemap_attribution',
labelKey: 'ADMIN_SETTINGS.BASEMAP_ATTRIBUTION_LABEL',
type: 'text',
},
],
},
{
color: '#f44336',
icon: 'admin_panel_settings',
Expand Down Expand Up @@ -350,7 +376,6 @@ export const SETTING_GROUPS: SettingGroup[] = [
})
export class AdminSettingsComponent implements OnInit {
private static readonly COLLAPSED_STORAGE_KEY = 'poracle-admin-settings-collapsed';

private readonly allDefinedKeys = new Set([
...SETTING_GROUPS.flatMap(g => g.settings.map(s => s.key)),
'uicons_pkmn',
Expand All @@ -371,8 +396,8 @@ export class AdminSettingsComponent implements OnInit {
]);

private readonly destroyRef = inject(DestroyRef);
private readonly dialog = inject(MatDialog);

private readonly dialog = inject(MatDialog);
private readonly i18n = inject(I18nService);

private readonly internalPrefixes = [
Expand Down Expand Up @@ -430,6 +455,8 @@ export class AdminSettingsComponent implements OnInit {
].some(key => this.i18n.instant(key).toLowerCase().includes(query));
});

protected readonly basemap = inject(BasemapService);

readonly bulkSaving = signal(false);

readonly collapsedGroups = signal<Set<string>>(AdminSettingsComponent.loadCollapsed());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';

import { PROJECTED_KEYS, SETTING_GROUPS } from './admin-settings.component';

/**
Expand Down Expand Up @@ -51,3 +54,25 @@ describe('PROJECTED_KEYS', () => {
expect(overlap).toEqual([]);
});
});

/**
* A group or a setting whose label key is absent from en.json renders the raw key -- "ADMIN_SETTINGS.
* GROUP_MAPS" sitting where a heading should be. Nothing else catches it: locale parity compares the
* locales against each other, so a key missing from all twelve files is consistent and passes.
*/
describe('SETTING_GROUPS translation keys', () => {
const english = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../assets/i18n/en.json'), 'utf8')) as {
ADMIN_SETTINGS: Record<string, string>;
};

const resolves = (key: string) => key.startsWith('ADMIN_SETTINGS.') && key.slice('ADMIN_SETTINGS.'.length) in english.ADMIN_SETTINGS;

it('resolves every group label', () => {
expect(SETTING_GROUPS.map(g => g.labelKey).filter(k => !resolves(k))).toEqual([]);
});

it('resolves every setting label and description', () => {
const keys = SETTING_GROUPS.flatMap(g => g.settings.flatMap(s => [s.labelKey, s.descriptionKey]));
expect(keys.filter(k => !resolves(k))).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
NgZone,
OnDestroy,
OnInit,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
Expand All @@ -28,6 +28,7 @@ import { firstValueFrom } from 'rxjs';
import { GeofenceData, GeofenceRegion, UserGeofence } from '../../../core/models';
import { AdminGeofenceService } from '../../../core/services/admin-geofence.service';
import { AreaService } from '../../../core/services/area.service';
import { BasemapService } from '../../../core/services/basemap.service';
import { I18nService } from '../../../core/services/i18n.service';
import { UserGeofenceService } from '../../../core/services/user-geofence.service';
import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/components/confirm-dialog/confirm-dialog.component';
Expand Down Expand Up @@ -70,6 +71,7 @@ export interface RegionGroup {
export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly adminGeofenceService = inject(AdminGeofenceService);
private readonly areaService = inject(AreaService);
private readonly basemap = inject(BasemapService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialog = inject(MatDialog);
private readonly elementRef = inject(ElementRef);
Expand Down Expand Up @@ -381,9 +383,7 @@ export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDe
zoomControl: false,
});

L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
maxZoom: 19,
}).addTo(map);
this.basemap.createLayer().addTo(map);

const color = GEOFENCE_STATUS_COLORS[geofence.status] || '#9e9e9e';
const polygon = L.polygon(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import {
AfterViewInit,
Component,
computed,
effect,
ElementRef,
EventEmitter,
inject,
Input,
input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
computed,
effect,
input,
output,
signal,
SimpleChanges,
ViewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import * as L from 'leaflet';

import 'leaflet-draw';

import { INITIAL_VIEW_MAX_ZOOM, LOCATION_ONLY_ZOOM, planInitialView } from './initial-view';
import { GeofenceData } from '../../../core/models';
import { BasemapService } from '../../../core/services/basemap.service';
import { I18nService } from '../../../core/services/i18n.service';
import { RegionOption, RegionSelectorComponent } from '../region-selector/region-selector.component';

Expand Down Expand Up @@ -73,6 +75,7 @@ interface RegionEntry {
})
export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy {
private allBoundsRect: L.LatLngBounds | null = null;
private readonly basemap = inject(BasemapService);
private customBoundsRect: L.LatLngBounds | null = null;
private customGeofenceLayer: L.LayerGroup = L.layerGroup();
private drawControl: L.Control.Draw | null = null;
Expand Down Expand Up @@ -510,11 +513,7 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy {
zoomControl: true,
}).setView([37.5, -77.4], 10);

L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://carto.com/">CARTO</a> &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>',
maxZoom: 19,
subdomains: 'abcd',
}).addTo(this.map);
this.basemap.createLayer().addTo(this.map);

// Once the user has touched the map, stop repositioning it. Raw DOM input events are used
// rather than Leaflet's movestart/zoomstart because those fire for our own fitBounds calls too,
Expand Down
Loading
Loading