feat(train): the instance's own map tiles, drawn by Train's style (ADR 0030, D50)
Protomaps' basemap cut for Europe and served by pmtiles beside Train's server (compose.maps.yaml), drawn by Train's own style, light and dark, which the server serves. Only people signed in get tiles: map.source hands a key that Caddy has the server check before each tile; nothing logs a tile. Also fixes .dockerignore, which left apps/train/ out of the build context, so Train's server's and BRouter's images couldn't have been built. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a848b71bd4
commit
190c3872f4
13 changed files with 3726 additions and 97 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_train_server/src/generated/serverpod.dart';
|
||||
import 'package:halkyon_train_server/src/map/instance_map.dart';
|
||||
import 'package:halkyon_train_server/src/pruning.dart';
|
||||
import 'package:halkyon_train_server/src/train_base.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
|
@ -26,6 +27,10 @@ Future<void> run(List<String> args) async {
|
|||
// The calls between the instance's services, on the web server
|
||||
// (ADR 0016); Caddy never forwards them.
|
||||
addInstanceRoutes(pod);
|
||||
// The instance's own tiles' style and their check (ADR 0030, D50), which
|
||||
// Caddy asks before each tile; no start where they couldn't be had.
|
||||
checkMapKeySecret(pod);
|
||||
addMapRoutes(pod);
|
||||
|
||||
await pod.start();
|
||||
|
||||
|
|
|
|||
|
|
@ -12,32 +12,38 @@
|
|||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
||||
/// The map this instance's workouts are drawn on (ADR 0030, D11, D22), as
|
||||
/// its operator sets it: a MapLibre style, its source's name and the credit
|
||||
/// it asks for, and where the source says what it sees. OpenFreeMap where
|
||||
/// nothing is set.
|
||||
/// The map this instance's workouts are drawn on (ADR 0030, D11, D22, D50),
|
||||
/// as its operator sets it: a MapLibre style, its source's name and the
|
||||
/// credit it asks for, and where the source says what it sees. OpenFreeMap
|
||||
/// where nothing is set; the instance's own tiles where its maps sidecar runs.
|
||||
abstract class MapSource
|
||||
implements _is.SerializableModel, _is.ProtocolSerialization {
|
||||
MapSource._({
|
||||
required this.name,
|
||||
required this.styleUrl,
|
||||
this.darkStyleUrl,
|
||||
required this.attribution,
|
||||
this.aboutUrl,
|
||||
this.tileKey,
|
||||
});
|
||||
|
||||
factory MapSource({
|
||||
required String name,
|
||||
required String styleUrl,
|
||||
String? darkStyleUrl,
|
||||
required String attribution,
|
||||
String? aboutUrl,
|
||||
String? tileKey,
|
||||
}) = _MapSourceImpl;
|
||||
|
||||
factory MapSource.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||
return MapSource(
|
||||
name: jsonSerialization['name'] as String,
|
||||
styleUrl: jsonSerialization['styleUrl'] as String,
|
||||
darkStyleUrl: jsonSerialization['darkStyleUrl'] as String?,
|
||||
attribution: jsonSerialization['attribution'] as String,
|
||||
aboutUrl: jsonSerialization['aboutUrl'] as String?,
|
||||
tileKey: jsonSerialization['tileKey'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -48,6 +54,10 @@ abstract class MapSource
|
|||
/// provider's key may be in it, so only people signed in get it.
|
||||
String styleUrl;
|
||||
|
||||
/// The style for the app's dark theme; null where the source has one
|
||||
/// alone (D50).
|
||||
String? darkStyleUrl;
|
||||
|
||||
/// The credit the map shows, as the source's terms ask.
|
||||
String attribution;
|
||||
|
||||
|
|
@ -55,14 +65,21 @@ abstract class MapSource
|
|||
/// where the operator names nothing.
|
||||
String? aboutUrl;
|
||||
|
||||
/// What each tile asks with, as `Authorization: Bearer`, where the tiles
|
||||
/// are the instance's own (D50): valid some 30 days, naming no one; null
|
||||
/// for every other source.
|
||||
String? tileKey;
|
||||
|
||||
/// Returns a shallow copy of this [MapSource]
|
||||
/// with some or all fields replaced by the given arguments.
|
||||
@_is.useResult
|
||||
MapSource copyWith({
|
||||
String? name,
|
||||
String? styleUrl,
|
||||
String? darkStyleUrl,
|
||||
String? attribution,
|
||||
String? aboutUrl,
|
||||
String? tileKey,
|
||||
});
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
|
|
@ -70,8 +87,10 @@ abstract class MapSource
|
|||
'__className__': 'MapSource',
|
||||
'name': name,
|
||||
'styleUrl': styleUrl,
|
||||
if (darkStyleUrl != null) 'darkStyleUrl': darkStyleUrl,
|
||||
'attribution': attribution,
|
||||
if (aboutUrl != null) 'aboutUrl': aboutUrl,
|
||||
if (tileKey != null) 'tileKey': tileKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -81,8 +100,10 @@ abstract class MapSource
|
|||
'__className__': 'MapSource',
|
||||
'name': name,
|
||||
'styleUrl': styleUrl,
|
||||
if (darkStyleUrl != null) 'darkStyleUrl': darkStyleUrl,
|
||||
'attribution': attribution,
|
||||
if (aboutUrl != null) 'aboutUrl': aboutUrl,
|
||||
if (tileKey != null) 'tileKey': tileKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -98,13 +119,17 @@ class _MapSourceImpl extends MapSource {
|
|||
_MapSourceImpl({
|
||||
required String name,
|
||||
required String styleUrl,
|
||||
String? darkStyleUrl,
|
||||
required String attribution,
|
||||
String? aboutUrl,
|
||||
String? tileKey,
|
||||
}) : super._(
|
||||
name: name,
|
||||
styleUrl: styleUrl,
|
||||
darkStyleUrl: darkStyleUrl,
|
||||
attribution: attribution,
|
||||
aboutUrl: aboutUrl,
|
||||
tileKey: tileKey,
|
||||
);
|
||||
|
||||
/// Returns a shallow copy of this [MapSource]
|
||||
|
|
@ -114,14 +139,18 @@ class _MapSourceImpl extends MapSource {
|
|||
MapSource copyWith({
|
||||
String? name,
|
||||
String? styleUrl,
|
||||
Object? darkStyleUrl = _Undefined,
|
||||
String? attribution,
|
||||
Object? aboutUrl = _Undefined,
|
||||
Object? tileKey = _Undefined,
|
||||
}) {
|
||||
return MapSource(
|
||||
name: name ?? this.name,
|
||||
styleUrl: styleUrl ?? this.styleUrl,
|
||||
darkStyleUrl: darkStyleUrl is String? ? darkStyleUrl : this.darkStyleUrl,
|
||||
attribution: attribution ?? this.attribution,
|
||||
aboutUrl: aboutUrl is String? ? aboutUrl : this.aboutUrl,
|
||||
tileKey: tileKey is String? ? tileKey : this.tileKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
130
apps/train/halkyon_train_server/lib/src/map/instance_map.dart
Normal file
130
apps/train/halkyon_train_server/lib/src/map/instance_map.dart
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/map/map_key.dart';
|
||||
import 'package:halkyon_train_server/src/map/map_source_config.dart';
|
||||
import 'package:halkyon_train_server/src/map/train_style.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// The instance's map (ADR 0030, D11, D22, D50): its source as the operator
|
||||
/// sets it and, where the tiles are its own, Train's style for them and the
|
||||
/// keys their tiles are asked with.
|
||||
class InstanceMap {
|
||||
InstanceMap(this.config, {this.keys});
|
||||
|
||||
/// The map of [pod], made the first time it is asked for, from the
|
||||
/// operator's environment and `mapKeySecret`. Tests set their own.
|
||||
factory InstanceMap.of(Serverpod pod) => _ofPod[pod] ??= InstanceMap(
|
||||
mapConfig(),
|
||||
keys: switch (pod.getPassword('mapKeySecret')) {
|
||||
final secret? when secret.length >= mapKeySecretMinLength => MapKeys(
|
||||
secret,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
static final _ofPod = Expando<InstanceMap>('InstanceMap');
|
||||
|
||||
/// Makes [map] the one of [pod].
|
||||
static void use(Serverpod pod, InstanceMap map) => _ofPod[pod] = map;
|
||||
|
||||
final MapConfig config;
|
||||
final MapKeys? keys;
|
||||
|
||||
/// The source as `map.source` hands it, with a key for the instance's
|
||||
/// own tiles.
|
||||
MapSource source() => switch ((config.tiles, keys)) {
|
||||
(_?, final keys?) => config.source.copyWith(tileKey: keys.issue()),
|
||||
_ => config.source,
|
||||
};
|
||||
|
||||
/// Train's style [name] (`light` or `dark`) in [language], as JSON; null
|
||||
/// for another name or where the tiles aren't the instance's own.
|
||||
String? style(String name, {String? language}) {
|
||||
final tiles = config.tiles;
|
||||
if (tiles == null || (name != 'light' && name != 'dark')) {
|
||||
return null;
|
||||
}
|
||||
return jsonEncode(
|
||||
trainStyle(tiles: tiles, dark: name == 'dark', language: language),
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether a tile asked for with [authorization] (its `Authorization`
|
||||
/// header's values) may be had: never where the tiles aren't the
|
||||
/// instance's own.
|
||||
bool allows(Iterable<String>? authorization) => switch ((
|
||||
config.tiles,
|
||||
keys,
|
||||
MapKeys.bearerOf(authorization),
|
||||
)) {
|
||||
(_?, final keys?, final key?) => keys.valid(key),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Throws a [StateError] where [pod]'s operator serves the instance's own
|
||||
/// tiles (`MAP_TILES_URL`) without a `mapKeySecret` of at least
|
||||
/// [mapKeySecretMinLength] characters: no tile could be had.
|
||||
void checkMapKeySecret(Serverpod pod) {
|
||||
final map = InstanceMap.of(pod);
|
||||
if (map.config.tiles != null && map.keys == null) {
|
||||
throw StateError(
|
||||
'The instance serves its own tiles (MAP_TILES_URL) but has no '
|
||||
'mapKeySecret of $mapKeySecretMinLength characters or more '
|
||||
'(SERVERPOD_PASSWORD_mapKeySecret). Generate one: openssl rand -hex 32',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Train's style at `/map/style/<name>.json` and the tiles' check at
|
||||
/// `/map/tile-key`, on [pod]'s web server, which Caddy reaches for them
|
||||
/// (`sidecars/train-maps.caddy`).
|
||||
void addMapRoutes(Serverpod pod) {
|
||||
if (pod.config.webServer == null) {
|
||||
return;
|
||||
}
|
||||
pod.webServer
|
||||
..addRoute(_StyleRoute(), '/map/style/*')
|
||||
..addRoute(_TileKeyRoute(), '/map/tile-key');
|
||||
}
|
||||
|
||||
/// Train's style: public, as nothing in it is secret; kept an hour.
|
||||
class _StyleRoute() extends Route {
|
||||
this : super(methods: {Method.get, Method.head});
|
||||
|
||||
@override
|
||||
Future<Result> handleCall(Session session, Request request) async {
|
||||
final file = request.url.pathSegments.last;
|
||||
final style = file.endsWith('.json')
|
||||
? InstanceMap.of(session.serverpod).style(
|
||||
file.substring(0, file.length - '.json'.length),
|
||||
language: request.url.queryParameters['lang'],
|
||||
)
|
||||
: null;
|
||||
if (style == null) {
|
||||
return Response.notFound();
|
||||
}
|
||||
return Response.ok(
|
||||
body: Body.fromString(style, mimeType: MimeType.json),
|
||||
headers: Headers.fromMap({
|
||||
HttpHeaders.cacheControlHeader: ['public, max-age=3600'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Caddy's question before each tile (`forward_auth`): 204 for a valid key,
|
||||
/// else 401. Nothing logged: a tile's address shows the area viewed.
|
||||
class _TileKeyRoute() extends Route {
|
||||
this : super(methods: {Method.get, Method.head});
|
||||
|
||||
@override
|
||||
Future<Result> handleCall(Session session, Request request) async =>
|
||||
InstanceMap.of(
|
||||
session.serverpod,
|
||||
).allows(request.headers[HttpHeaders.authorizationHeader])
|
||||
? Response(204)
|
||||
: Response.unauthorized();
|
||||
}
|
||||
|
|
@ -1,13 +1,16 @@
|
|||
import 'package:halkyon_train_server/src/generated/protocol.dart';
|
||||
import 'package:halkyon_train_server/src/map/map_source_config.dart';
|
||||
import 'package:halkyon_train_server/src/map/instance_map.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// The map workouts are drawn on (ADR 0030, D11, D22), for the people signed
|
||||
/// in: an operator's provider may have its key in the style's address.
|
||||
/// The map workouts are drawn on (ADR 0030, D11, D22, D50), for the people
|
||||
/// signed in: an operator's provider may have its key in the style's
|
||||
/// address, and the instance's own tiles are theirs alone.
|
||||
class MapEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// The instance's map source, as its operator sets it.
|
||||
Future<MapSource> source(Session session) async => mapSource();
|
||||
/// The instance's map source, as its operator sets it, with a key for its
|
||||
/// own tiles.
|
||||
Future<MapSource> source(Session session) async =>
|
||||
InstanceMap.of(session.serverpod).source();
|
||||
}
|
||||
|
|
|
|||
69
apps/train/halkyon_train_server/lib/src/map/map_key.dart
Normal file
69
apps/train/halkyon_train_server/lib/src/map/map_key.dart
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// The fewest characters of `mapKeySecret`: whoever has it makes keys for
|
||||
/// the instance's tiles.
|
||||
const mapKeySecretMinLength = 32;
|
||||
|
||||
/// Keys for the instance's own tiles (ADR 0030, D50): the last day a key is
|
||||
/// valid, counted from 1970, and an HMAC-SHA256 of it under the instance's
|
||||
/// secret, `m1.<day>.<mac>`. A key names no one, so that the tiles asked
|
||||
/// with it point at no person; every key issued on a day is the same.
|
||||
class MapKeys(final String _secret) {
|
||||
/// How long a key is valid.
|
||||
static const lifetime = Duration(days: 30);
|
||||
|
||||
/// A key valid for [lifetime] from now.
|
||||
String issue() {
|
||||
final day = _today() + lifetime.inDays;
|
||||
return 'm1.$day.${_mac(day)}';
|
||||
}
|
||||
|
||||
/// Whether [key] is one of [issue]'s still valid.
|
||||
bool valid(String key) {
|
||||
final parts = key.split('.');
|
||||
if (parts.length != 3 || parts[0] != 'm1') {
|
||||
return false;
|
||||
}
|
||||
final day = int.tryParse(parts[1]);
|
||||
if (day == null || day < _today() || day > _today() + lifetime.inDays) {
|
||||
return false;
|
||||
}
|
||||
final expected = utf8.encode(_mac(day));
|
||||
final given = utf8.encode(parts[2]);
|
||||
if (expected.length != given.length) {
|
||||
return false;
|
||||
}
|
||||
// In constant time, so that no answer's time gives a byte away.
|
||||
var difference = 0;
|
||||
for (var i = 0; i < expected.length; i++) {
|
||||
difference |= expected[i] ^ given[i];
|
||||
}
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
/// The key in an `Authorization` header's [values], if it is a bearer's.
|
||||
static String? bearerOf(Iterable<String>? values) {
|
||||
for (final value in values ?? const <String>[]) {
|
||||
final match = RegExp(r'^Bearer\s+(\S+)$').firstMatch(value.trim());
|
||||
if (match != null) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _mac(int day) => base64Url
|
||||
.encode(
|
||||
Hmac(
|
||||
sha256,
|
||||
utf8.encode(_secret),
|
||||
).convert(utf8.encode('m1.$day')).bytes,
|
||||
)
|
||||
.replaceAll('=', '');
|
||||
|
||||
static int _today() =>
|
||||
clock.now().toUtc().millisecondsSinceEpoch ~/ Duration.millisecondsPerDay;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
### The map this instance's workouts are drawn on (ADR 0030, D11, D22), as
|
||||
### its operator sets it: a MapLibre style, its source's name and the credit
|
||||
### it asks for, and where the source says what it sees. OpenFreeMap where
|
||||
### nothing is set.
|
||||
### The map this instance's workouts are drawn on (ADR 0030, D11, D22, D50),
|
||||
### as its operator sets it: a MapLibre style, its source's name and the
|
||||
### credit it asks for, and where the source says what it sees. OpenFreeMap
|
||||
### where nothing is set; the instance's own tiles where its maps sidecar runs.
|
||||
class: MapSource
|
||||
fields:
|
||||
### What the app names as drawing the map, as `OpenFreeMap`.
|
||||
|
|
@ -9,8 +9,15 @@ fields:
|
|||
### The MapLibre (Mapbox GL) style the vector tiles are drawn by; a
|
||||
### provider's key may be in it, so only people signed in get it.
|
||||
styleUrl: String
|
||||
### The style for the app's dark theme; null where the source has one
|
||||
### alone (D50).
|
||||
darkStyleUrl: String?
|
||||
### The credit the map shows, as the source's terms ask.
|
||||
attribution: String
|
||||
### Where the source says what it keeps of those viewing its maps; null
|
||||
### where the operator names nothing.
|
||||
aboutUrl: String?
|
||||
### What each tile asks with, as `Authorization: Bearer`, where the tiles
|
||||
### are the instance's own (D50): valid some 30 days, naming no one; null
|
||||
### for every other source.
|
||||
tileKey: String?
|
||||
|
|
|
|||
|
|
@ -11,27 +11,58 @@ final openFreeMap = MapSource(
|
|||
aboutUrl: 'https://openfreemap.org',
|
||||
);
|
||||
|
||||
/// The instance's map source, as its operator's environment sets it.
|
||||
MapSource mapSource() => mapSourceFrom(Platform.environment);
|
||||
/// The instance's map, as its operator's environment sets it: its source
|
||||
/// and, where the tiles are the instance's own, their tiles template.
|
||||
typedef MapConfig = ({MapSource source, String? tiles});
|
||||
|
||||
/// The map source [environment] sets: `MAP_STYLE_URL`, a MapLibre style over
|
||||
/// HTTPS, with `MAP_SOURCE_NAME` (else the style's host), `MAP_ATTRIBUTION`
|
||||
/// (else OpenStreetMap's credit) and `MAP_ABOUT_URL` (HTTPS, else none);
|
||||
/// [openFreeMap] without a style, or with one that isn't an HTTPS address.
|
||||
MapSource mapSourceFrom(Map<String, String> environment) {
|
||||
final style = _https(environment['MAP_STYLE_URL']);
|
||||
if (style == null) {
|
||||
return openFreeMap;
|
||||
}
|
||||
/// The instance's map, from its environment.
|
||||
MapConfig mapConfig() => mapConfigFrom(Platform.environment);
|
||||
|
||||
/// The map [environment] sets. `MAP_TILES_URL` (D50), the instance's own
|
||||
/// Protomaps tiles as an HTTPS `{z}/{x}/{y}` template, drawn by Train's
|
||||
/// style, served beside them at `/map/style/light.json` and `dark.json`;
|
||||
/// else `MAP_STYLE_URL`, a provider's MapLibre style over HTTPS; else
|
||||
/// [openFreeMap]. Both with `MAP_SOURCE_NAME` (else the host),
|
||||
/// `MAP_ATTRIBUTION` (else OpenStreetMap's credit, and Protomaps' for the
|
||||
/// instance's tiles) and `MAP_ABOUT_URL` (HTTPS, else none).
|
||||
MapConfig mapConfigFrom(Map<String, String> environment) {
|
||||
String? set(String name) => switch (environment[name]?.trim()) {
|
||||
final value? when value.isNotEmpty => value,
|
||||
_ => null,
|
||||
};
|
||||
return MapSource(
|
||||
name: set('MAP_SOURCE_NAME') ?? style.host,
|
||||
styleUrl: style.toString(),
|
||||
attribution: set('MAP_ATTRIBUTION') ?? '© OpenStreetMap contributors',
|
||||
aboutUrl: _https(environment['MAP_ABOUT_URL'])?.toString(),
|
||||
final about = _https(environment['MAP_ABOUT_URL'])?.toString();
|
||||
if (_template(environment['MAP_TILES_URL']) case (final tiles, final at)) {
|
||||
String style(String name) => Uri(
|
||||
scheme: 'https',
|
||||
host: at.host,
|
||||
port: at.port,
|
||||
path: '/map/style/$name.json',
|
||||
).toString();
|
||||
return (
|
||||
source: MapSource(
|
||||
name: set('MAP_SOURCE_NAME') ?? at.host,
|
||||
styleUrl: style('light'),
|
||||
darkStyleUrl: style('dark'),
|
||||
attribution:
|
||||
set('MAP_ATTRIBUTION') ??
|
||||
'© OpenStreetMap contributors · Protomaps',
|
||||
aboutUrl: about,
|
||||
),
|
||||
tiles: tiles,
|
||||
);
|
||||
}
|
||||
final style = _https(environment['MAP_STYLE_URL']);
|
||||
if (style == null) {
|
||||
return (source: openFreeMap, tiles: null);
|
||||
}
|
||||
return (
|
||||
source: MapSource(
|
||||
name: set('MAP_SOURCE_NAME') ?? style.host,
|
||||
styleUrl: style.toString(),
|
||||
attribution: set('MAP_ATTRIBUTION') ?? '© OpenStreetMap contributors',
|
||||
aboutUrl: about,
|
||||
),
|
||||
tiles: null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -42,3 +73,18 @@ Uri? _https(String? value) {
|
|||
? uri
|
||||
: null;
|
||||
}
|
||||
|
||||
/// [value] as a tiles template, with the address before its first `{`;
|
||||
/// null unless it is HTTPS and has `{z}`, `{x}` and `{y}`.
|
||||
(String, Uri)? _template(String? value) {
|
||||
final template = value?.trim() ?? '';
|
||||
if (!['{z}', '{x}', '{y}'].every(template.contains)) {
|
||||
return null;
|
||||
}
|
||||
final at = _https(template.substring(0, template.indexOf('{')));
|
||||
return at == null ? null : (template, at);
|
||||
}
|
||||
|
||||
/// The source [environment] sets ([mapConfigFrom]'s).
|
||||
MapSource mapSourceFrom(Map<String, String> environment) =>
|
||||
mapConfigFrom(environment).source;
|
||||
|
|
|
|||
522
apps/train/halkyon_train_server/lib/src/map/train_style.dart
Normal file
522
apps/train/halkyon_train_server/lib/src/map/train_style.dart
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
/// The languages the style names places in where the tiles have them; any
|
||||
/// other leaves the tiles' own names (each place's local one).
|
||||
const styleLanguages = {'en', 'de'};
|
||||
|
||||
/// Train's map style (ADR 0030, D50): a MapLibre style for Protomaps'
|
||||
/// basemap (schema v4) at [tiles], an `{z}/{x}/{y}` template, light or
|
||||
/// [dark], for running and riding: land, woods, parks, water, roads by class
|
||||
/// with paths, tracks and cycleways set apart, rail, buildings from zoom 14,
|
||||
/// boundaries, and the names of places, roads and water in [language] where
|
||||
/// the tiles have it ([styleLanguages]). No icons, no glyphs: the app draws
|
||||
/// text with its own fonts. Only expressions vector_tile_renderer reads.
|
||||
Map<String, Object?> trainStyle({
|
||||
required String tiles,
|
||||
required bool dark,
|
||||
String? language,
|
||||
}) {
|
||||
final colours = dark ? _dark : _light;
|
||||
final name = styleLanguages.contains(language)
|
||||
? [
|
||||
'coalesce',
|
||||
['get', 'name:$language'],
|
||||
['get', 'name'],
|
||||
]
|
||||
: ['get', 'name'];
|
||||
Map<String, Object?> layer(
|
||||
String id,
|
||||
String type,
|
||||
String sourceLayer, {
|
||||
Object? filter,
|
||||
int? minzoom,
|
||||
Map<String, Object?>? layout,
|
||||
required Map<String, Object?> paint,
|
||||
}) => {
|
||||
'id': id,
|
||||
'type': type,
|
||||
'source': 'protomaps',
|
||||
'source-layer': sourceLayer,
|
||||
'filter': ?filter,
|
||||
'minzoom': ?minzoom,
|
||||
'layout': ?layout,
|
||||
'paint': paint,
|
||||
};
|
||||
Object kindIn(List<String> kinds) => [
|
||||
'match',
|
||||
['get', 'kind'],
|
||||
kinds,
|
||||
true,
|
||||
false,
|
||||
];
|
||||
Object detailIn(List<String> details) => [
|
||||
'match',
|
||||
['get', 'kind_detail'],
|
||||
details,
|
||||
true,
|
||||
false,
|
||||
];
|
||||
Object width(num at12, num at18) => [
|
||||
'interpolate',
|
||||
['exponential', 1.6],
|
||||
['zoom'],
|
||||
12,
|
||||
at12,
|
||||
18,
|
||||
at18,
|
||||
];
|
||||
Map<String, Object?> label(String colour) => {
|
||||
'text-color': colour,
|
||||
'text-halo-color': colours.halo,
|
||||
'text-halo-width': 1.5,
|
||||
};
|
||||
final roadLayout = {'line-cap': 'round', 'line-join': 'round'};
|
||||
const majorRoads = ['highway', 'major_road'];
|
||||
const paths = ['footway', 'path', 'pedestrian', 'steps', 'bridleway'];
|
||||
return {
|
||||
'version': 8,
|
||||
'name': dark ? 'Halkyon Train, dark' : 'Halkyon Train',
|
||||
'sources': {
|
||||
'protomaps': {
|
||||
'type': 'vector',
|
||||
'tiles': [tiles],
|
||||
'minzoom': 0,
|
||||
'maxzoom': 15,
|
||||
'attribution':
|
||||
'<a href="https://www.openstreetmap.org/copyright">'
|
||||
'© OpenStreetMap contributors</a> · '
|
||||
'<a href="https://protomaps.com">Protomaps</a>',
|
||||
},
|
||||
},
|
||||
'layers': [
|
||||
{
|
||||
'id': 'background',
|
||||
'type': 'background',
|
||||
'paint': {'background-color': colours.water},
|
||||
},
|
||||
layer('earth', 'fill', 'earth', paint: {'fill-color': colours.land}),
|
||||
layer(
|
||||
'landcover',
|
||||
'fill',
|
||||
'landcover',
|
||||
paint: {
|
||||
'fill-color': [
|
||||
'match',
|
||||
['get', 'kind'],
|
||||
['forest', 'scrub'],
|
||||
colours.wood,
|
||||
['grassland', 'farmland'],
|
||||
colours.grass,
|
||||
'glacier',
|
||||
colours.ice,
|
||||
'barren',
|
||||
colours.sand,
|
||||
colours.urban,
|
||||
],
|
||||
},
|
||||
),
|
||||
layer(
|
||||
'landuse-wood',
|
||||
'fill',
|
||||
'landuse',
|
||||
filter: kindIn(['forest', 'wood', 'orchard', 'scrub']),
|
||||
paint: {'fill-color': colours.wood},
|
||||
),
|
||||
layer(
|
||||
'landuse-park',
|
||||
'fill',
|
||||
'landuse',
|
||||
filter: kindIn([
|
||||
'park',
|
||||
'garden',
|
||||
'grass',
|
||||
'meadow',
|
||||
'recreation_ground',
|
||||
'playground',
|
||||
'dog_park',
|
||||
'golf_course',
|
||||
'nature_reserve',
|
||||
'national_park',
|
||||
'protected_area',
|
||||
'cemetery',
|
||||
'camp_site',
|
||||
]),
|
||||
paint: {'fill-color': colours.park},
|
||||
),
|
||||
layer(
|
||||
'landuse-pitch',
|
||||
'fill',
|
||||
'landuse',
|
||||
filter: kindIn(['pitch', 'stadium']),
|
||||
paint: {'fill-color': colours.pitch},
|
||||
),
|
||||
layer(
|
||||
'landuse-sand',
|
||||
'fill',
|
||||
'landuse',
|
||||
filter: kindIn(['beach', 'sand', 'bare_rock']),
|
||||
paint: {'fill-color': colours.sand},
|
||||
),
|
||||
layer(
|
||||
'landuse-wetland',
|
||||
'fill',
|
||||
'landuse',
|
||||
filter: kindIn(['wetland']),
|
||||
paint: {'fill-color': colours.wetland},
|
||||
),
|
||||
layer(
|
||||
'landuse-built',
|
||||
'fill',
|
||||
'landuse',
|
||||
filter: kindIn([
|
||||
'industrial',
|
||||
'commercial',
|
||||
'railway',
|
||||
'military',
|
||||
'aerodrome',
|
||||
'hospital',
|
||||
'school',
|
||||
'college',
|
||||
'university',
|
||||
'kindergarten',
|
||||
]),
|
||||
paint: {'fill-color': colours.built},
|
||||
),
|
||||
layer(
|
||||
'landuse-runway',
|
||||
'fill',
|
||||
'landuse',
|
||||
filter: kindIn(['runway', 'taxiway']),
|
||||
paint: {'fill-color': colours.runway},
|
||||
),
|
||||
layer(
|
||||
'water',
|
||||
'fill',
|
||||
'water',
|
||||
filter: [
|
||||
'==',
|
||||
['geometry-type'],
|
||||
'Polygon',
|
||||
],
|
||||
paint: {'fill-color': colours.water},
|
||||
),
|
||||
layer(
|
||||
'water-lines',
|
||||
'line',
|
||||
'water',
|
||||
filter: [
|
||||
'==',
|
||||
['geometry-type'],
|
||||
'LineString',
|
||||
],
|
||||
minzoom: 9,
|
||||
layout: roadLayout,
|
||||
paint: {
|
||||
'line-color': colours.water,
|
||||
'line-width': [
|
||||
'match',
|
||||
['get', 'kind_detail'],
|
||||
['river', 'canal'],
|
||||
width(1.5, 12),
|
||||
width(0.5, 3),
|
||||
],
|
||||
},
|
||||
),
|
||||
layer(
|
||||
'buildings',
|
||||
'fill',
|
||||
'buildings',
|
||||
filter: kindIn(['building', 'building_part']),
|
||||
minzoom: 14,
|
||||
paint: {'fill-color': colours.building},
|
||||
),
|
||||
layer(
|
||||
'boundaries',
|
||||
'line',
|
||||
'boundaries',
|
||||
filter: kindIn(['country', 'region']),
|
||||
paint: {
|
||||
'line-color': colours.boundary,
|
||||
'line-width': [
|
||||
'match',
|
||||
['get', 'kind'],
|
||||
'country',
|
||||
1.5,
|
||||
0.8,
|
||||
],
|
||||
'line-dasharray': [3, 2],
|
||||
},
|
||||
),
|
||||
layer(
|
||||
'rail',
|
||||
'line',
|
||||
'roads',
|
||||
filter: kindIn(['rail']),
|
||||
minzoom: 10,
|
||||
paint: {'line-color': colours.rail, 'line-width': width(0.8, 3)},
|
||||
),
|
||||
layer(
|
||||
'roads-major-casing',
|
||||
'line',
|
||||
'roads',
|
||||
filter: kindIn(majorRoads),
|
||||
minzoom: 7,
|
||||
layout: roadLayout,
|
||||
paint: {'line-color': colours.casing, 'line-width': width(3, 22)},
|
||||
),
|
||||
layer(
|
||||
'roads-minor-casing',
|
||||
'line',
|
||||
'roads',
|
||||
filter: kindIn(['minor_road']),
|
||||
minzoom: 13,
|
||||
layout: roadLayout,
|
||||
paint: {'line-color': colours.casing, 'line-width': width(1.5, 14)},
|
||||
),
|
||||
layer(
|
||||
'roads-minor',
|
||||
'line',
|
||||
'roads',
|
||||
filter: kindIn(['minor_road']),
|
||||
minzoom: 11,
|
||||
layout: roadLayout,
|
||||
paint: {'line-color': colours.road, 'line-width': width(0.8, 11)},
|
||||
),
|
||||
layer(
|
||||
'roads-major',
|
||||
'line',
|
||||
'roads',
|
||||
filter: kindIn(majorRoads),
|
||||
minzoom: 5,
|
||||
layout: roadLayout,
|
||||
paint: {
|
||||
'line-color': [
|
||||
'match',
|
||||
['get', 'kind'],
|
||||
'highway',
|
||||
colours.highway,
|
||||
colours.major,
|
||||
],
|
||||
'line-width': width(2, 18),
|
||||
},
|
||||
),
|
||||
// The ways Train is for, each set apart: tracks brown, footpaths
|
||||
// dotted, cycleways in a colour of their own.
|
||||
layer(
|
||||
'tracks',
|
||||
'line',
|
||||
'roads',
|
||||
filter: [
|
||||
'all',
|
||||
kindIn(['path', 'minor_road']),
|
||||
detailIn(['track']),
|
||||
],
|
||||
minzoom: 12,
|
||||
layout: roadLayout,
|
||||
paint: {
|
||||
'line-color': colours.track,
|
||||
'line-width': width(1, 4),
|
||||
'line-dasharray': [4, 1.5],
|
||||
},
|
||||
),
|
||||
layer(
|
||||
'footpaths',
|
||||
'line',
|
||||
'roads',
|
||||
filter: [
|
||||
'all',
|
||||
kindIn(['path']),
|
||||
detailIn(paths),
|
||||
],
|
||||
minzoom: 13,
|
||||
paint: {
|
||||
'line-color': colours.path,
|
||||
'line-width': width(1, 3.5),
|
||||
'line-dasharray': [1.5, 1.5],
|
||||
},
|
||||
),
|
||||
layer(
|
||||
'cycleways',
|
||||
'line',
|
||||
'roads',
|
||||
filter: [
|
||||
'all',
|
||||
kindIn(['path']),
|
||||
detailIn(['cycleway']),
|
||||
],
|
||||
minzoom: 12,
|
||||
layout: roadLayout,
|
||||
paint: {'line-color': colours.cycleway, 'line-width': width(1, 4)},
|
||||
),
|
||||
layer(
|
||||
'water-names',
|
||||
'symbol',
|
||||
'water',
|
||||
filter: ['has', 'name'],
|
||||
minzoom: 12,
|
||||
layout: {
|
||||
'symbol-placement': 'line',
|
||||
'text-field': name,
|
||||
'text-size': 12,
|
||||
},
|
||||
paint: label(colours.waterText),
|
||||
),
|
||||
layer(
|
||||
'road-names',
|
||||
'symbol',
|
||||
'roads',
|
||||
filter: [
|
||||
'all',
|
||||
['has', 'name'],
|
||||
kindIn(['highway', 'major_road', 'minor_road', 'path']),
|
||||
],
|
||||
minzoom: 14,
|
||||
layout: {
|
||||
'symbol-placement': 'line',
|
||||
'text-field': name,
|
||||
'text-size': 12,
|
||||
},
|
||||
paint: label(colours.text),
|
||||
),
|
||||
layer(
|
||||
'places-local',
|
||||
'symbol',
|
||||
'places',
|
||||
filter: kindIn(['neighbourhood', 'macrohood']),
|
||||
minzoom: 13,
|
||||
layout: {
|
||||
'text-field': name,
|
||||
'text-size': 12,
|
||||
'text-transform': 'uppercase',
|
||||
'text-letter-spacing': 0.08,
|
||||
},
|
||||
paint: label(colours.minorText),
|
||||
),
|
||||
layer(
|
||||
'places-locality',
|
||||
'symbol',
|
||||
'places',
|
||||
filter: kindIn(['locality']),
|
||||
layout: {
|
||||
'text-field': name,
|
||||
'text-size': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'population_rank'],
|
||||
1,
|
||||
11,
|
||||
15,
|
||||
18,
|
||||
],
|
||||
},
|
||||
paint: label(colours.text),
|
||||
),
|
||||
layer(
|
||||
'places-region',
|
||||
'symbol',
|
||||
'places',
|
||||
filter: kindIn(['region']),
|
||||
layout: {
|
||||
'text-field': name,
|
||||
'text-size': 12,
|
||||
'text-transform': 'uppercase',
|
||||
},
|
||||
paint: label(colours.minorText),
|
||||
),
|
||||
layer(
|
||||
'places-country',
|
||||
'symbol',
|
||||
'places',
|
||||
filter: kindIn(['country']),
|
||||
layout: {'text-field': name, 'text-size': 15},
|
||||
paint: label(colours.text),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/// A theme's colours: quiet, so that a track in cobalt stands out.
|
||||
final class const _Colours({
|
||||
required final String land,
|
||||
required final String water,
|
||||
required final String waterText,
|
||||
required final String wood,
|
||||
required final String grass,
|
||||
required final String park,
|
||||
required final String pitch,
|
||||
required final String sand,
|
||||
required final String wetland,
|
||||
required final String ice,
|
||||
required final String urban,
|
||||
required final String built,
|
||||
required final String runway,
|
||||
required final String building,
|
||||
required final String boundary,
|
||||
required final String rail,
|
||||
required final String casing,
|
||||
required final String road,
|
||||
required final String major,
|
||||
required final String highway,
|
||||
required final String track,
|
||||
required final String path,
|
||||
required final String cycleway,
|
||||
required final String text,
|
||||
required final String minorText,
|
||||
required final String halo,
|
||||
});
|
||||
|
||||
const _light = _Colours(
|
||||
land: '#f4f1ea',
|
||||
water: '#b9d3e3',
|
||||
waterText: '#3f6a86',
|
||||
wood: '#cfe0c3',
|
||||
grass: '#e6ecd6',
|
||||
park: '#d8e8cc',
|
||||
pitch: '#cde3c0',
|
||||
sand: '#eee4c8',
|
||||
wetland: '#d6e4dc',
|
||||
ice: '#fbfcfd',
|
||||
urban: '#ece8e0',
|
||||
built: '#e7e2dd',
|
||||
runway: '#dcd8d3',
|
||||
building: '#dfd9d1',
|
||||
boundary: '#9b8fa8',
|
||||
rail: '#a7a4a0',
|
||||
casing: '#d2ccc2',
|
||||
road: '#ffffff',
|
||||
major: '#fdf3dc',
|
||||
highway: '#f6dcb1',
|
||||
track: '#9a6b3c',
|
||||
path: '#b0563a',
|
||||
cycleway: '#2f7f7a',
|
||||
text: '#3a3631',
|
||||
minorText: '#6e675e',
|
||||
halo: '#f9f7f2',
|
||||
);
|
||||
|
||||
const _dark = _Colours(
|
||||
land: '#1d2024',
|
||||
water: '#1b3042',
|
||||
waterText: '#8fb2cb',
|
||||
wood: '#22302a',
|
||||
grass: '#222a24',
|
||||
park: '#233127',
|
||||
pitch: '#253427',
|
||||
sand: '#2e2c25',
|
||||
wetland: '#20302d',
|
||||
ice: '#2b3036',
|
||||
urban: '#212428',
|
||||
built: '#25272b',
|
||||
runway: '#2c2e32',
|
||||
building: '#2a2c30',
|
||||
boundary: '#7d7290',
|
||||
rail: '#55575b',
|
||||
casing: '#15171a',
|
||||
road: '#3a3d42',
|
||||
major: '#4b4639',
|
||||
highway: '#6a573a',
|
||||
track: '#b88a5a',
|
||||
path: '#d07a5c',
|
||||
cycleway: '#5fb5ae',
|
||||
text: '#e2ded8',
|
||||
minorText: '#a29b92',
|
||||
halo: '#1d2024',
|
||||
);
|
||||
|
|
@ -9,6 +9,7 @@ resolution: workspace
|
|||
|
||||
dependencies:
|
||||
clock: ^1.1.3
|
||||
crypto: ^3.0.7
|
||||
# What every Halkyon server has (ADR 0014): signing in, the account and its
|
||||
# deletion, pruning, the instance's basics. A Serverpod module among the
|
||||
# repository's shared packages.
|
||||
|
|
|
|||
|
|
@ -1,25 +1,85 @@
|
|||
import 'package:halkyon_train_server/src/map/instance_map.dart';
|
||||
import 'package:halkyon_train_server/src/map/map_key.dart';
|
||||
import 'package:halkyon_train_server/src/map/map_source_config.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
/// The map the app draws workouts on, as the instance names it (ADR 0030,
|
||||
/// D11, D22): for the people signed in, as the provider's key may be in it.
|
||||
/// D11, D22, D50): for the people signed in, as the provider's key may be in
|
||||
/// it and the instance's own tiles are theirs alone.
|
||||
void main() {
|
||||
withServerpod('The map source', (sessionBuilder, endpoints) {
|
||||
final signedIn = sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
);
|
||||
final ownTiles = InstanceMap(
|
||||
mapConfigFrom(const {
|
||||
'MAP_TILES_URL':
|
||||
'https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt',
|
||||
}),
|
||||
keys: MapKeys('s' * mapKeySecretMinLength),
|
||||
);
|
||||
late Serverpod pod;
|
||||
|
||||
setUp(() => pod = sessionBuilder.build().serverpod);
|
||||
tearDown(() => InstanceMap.use(pod, InstanceMap(mapConfigFrom(const {}))));
|
||||
|
||||
test('names OpenFreeMap to someone signed in, where the operator sets '
|
||||
'nothing', () async {
|
||||
final source = await endpoints.map.source(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
const Uuid().v7(),
|
||||
{},
|
||||
),
|
||||
),
|
||||
);
|
||||
'nothing, with no key', () async {
|
||||
InstanceMap.use(pod, InstanceMap(mapConfigFrom(const {})));
|
||||
final source = await endpoints.map.source(signedIn);
|
||||
|
||||
expect(source.name, 'OpenFreeMap');
|
||||
expect(source.styleUrl, startsWith('https://tiles.openfreemap.org/'));
|
||||
expect(source.tileKey, isNull);
|
||||
});
|
||||
|
||||
test("hands the instance's own tiles with Train's styles and a key "
|
||||
'their check takes', () async {
|
||||
InstanceMap.use(pod, ownTiles);
|
||||
final source = await endpoints.map.source(signedIn);
|
||||
|
||||
expect(source.darkStyleUrl, endsWith('/map/style/dark.json'));
|
||||
expect(ownTiles.allows(['Bearer ${source.tileKey}']), isTrue);
|
||||
expect(ownTiles.style('light'), contains('/tiles/europe/{z}/{x}/{y}'));
|
||||
});
|
||||
|
||||
test("refuses a tile without a key, or with one it didn't make", () {
|
||||
final other = MapKeys('t' * mapKeySecretMinLength).issue();
|
||||
expect(ownTiles.allows(null), isFalse);
|
||||
expect(ownTiles.allows(['Bearer $other']), isFalse);
|
||||
expect(ownTiles.allows([other]), isFalse);
|
||||
});
|
||||
|
||||
test('refuses every tile, and has no style, where the tiles are no '
|
||||
"instance's own", () {
|
||||
final provider = InstanceMap(
|
||||
mapConfigFrom(const {
|
||||
'MAP_STYLE_URL': 'https://maps.example.org/style.json',
|
||||
}),
|
||||
keys: MapKeys('s' * mapKeySecretMinLength),
|
||||
);
|
||||
final key = ownTiles.keys!.issue();
|
||||
expect(provider.allows(['Bearer $key']), isFalse);
|
||||
expect(provider.style('light'), isNull);
|
||||
expect(ownTiles.style('satellite'), isNull);
|
||||
});
|
||||
|
||||
test("won't start serving its own tiles without a secret to key them", () {
|
||||
InstanceMap.use(
|
||||
pod,
|
||||
InstanceMap(
|
||||
mapConfigFrom(const {
|
||||
'MAP_TILES_URL': 'https://train.example.org/tiles/{z}/{x}/{y}.mvt',
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(() => checkMapKeySecret(pod), throwsStateError);
|
||||
});
|
||||
|
||||
test('names nothing to nobody signed in', () async {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_train_server/src/map/map_key.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// The keys the instance's own tiles are asked with (ADR 0030, D50): valid
|
||||
/// 30 days, naming no one, refused once expired or changed.
|
||||
void main() {
|
||||
final keys = MapKeys('s' * mapKeySecretMinLength);
|
||||
final day = DateTime.utc(2026, 10, 29, 12);
|
||||
|
||||
test('a key issued is valid, and the same all day', () {
|
||||
withClock(Clock.fixed(day), () {
|
||||
final key = keys.issue();
|
||||
expect(key, startsWith('m1.'));
|
||||
expect(keys.valid(key), isTrue);
|
||||
withClock(Clock.fixed(day.add(const Duration(hours: 11))), () {
|
||||
expect(keys.issue(), key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('a key is valid for 30 days, and refused after', () {
|
||||
final key = withClock(Clock.fixed(day), keys.issue);
|
||||
withClock(Clock.fixed(day.add(const Duration(days: 30))), () {
|
||||
expect(keys.valid(key), isTrue);
|
||||
});
|
||||
withClock(Clock.fixed(day.add(const Duration(days: 31))), () {
|
||||
expect(keys.valid(key), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('a key changed, made under another secret, or naming a day beyond '
|
||||
"a key's lifetime is refused", () {
|
||||
withClock(Clock.fixed(day), () {
|
||||
final key = keys.issue();
|
||||
final [prefix, issuedDay, mac] = key.split('.');
|
||||
final otherSecret = MapKeys('t' * mapKeySecretMinLength).issue();
|
||||
final later = '$prefix.${int.parse(issuedDay) + 400}.$mac';
|
||||
final changed =
|
||||
'$prefix.$issuedDay.${mac.substring(1)}${mac[0] == 'A' ? 'B' : 'A'}';
|
||||
for (final refused in [
|
||||
otherSecret,
|
||||
later,
|
||||
changed,
|
||||
'$prefix.$issuedDay',
|
||||
'm2.$issuedDay.$mac',
|
||||
'',
|
||||
]) {
|
||||
expect(keys.valid(refused), isFalse, reason: refused);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("the key is a bearer's in the Authorization header", () {
|
||||
expect(MapKeys.bearerOf(['Bearer m1.1.abc']), 'm1.1.abc');
|
||||
expect(MapKeys.bearerOf(['Basic dXNlcjpwdw==']), isNull);
|
||||
expect(MapKeys.bearerOf(['Bearer']), isNull);
|
||||
expect(MapKeys.bearerOf(null), isNull);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import 'package:halkyon_train_server/src/map/map_source_config.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// The instance's map source as its operator sets it (ADR 0030, D11, D22).
|
||||
/// The instance's map source as its operator sets it (ADR 0030, D11, D22,
|
||||
/// D50).
|
||||
void main() {
|
||||
test('without a setting it is OpenFreeMap, named, with its attribution', () {
|
||||
final source = mapSourceFrom(const {});
|
||||
|
|
@ -65,4 +66,41 @@ void main() {
|
|||
|
||||
expect(source.aboutUrl, isNull);
|
||||
});
|
||||
|
||||
test("the instance's own tiles are drawn by Train's style beside them, "
|
||||
'light and dark, and come before a provider', () {
|
||||
final config = mapConfigFrom(const {
|
||||
'MAP_TILES_URL': 'https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt',
|
||||
'MAP_STYLE_URL': 'https://maps.example.org/style.json',
|
||||
});
|
||||
|
||||
expect(
|
||||
config.tiles,
|
||||
'https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt',
|
||||
);
|
||||
expect(config.source.name, 'train.example.org');
|
||||
expect(
|
||||
config.source.styleUrl,
|
||||
'https://train.example.org/map/style/light.json',
|
||||
);
|
||||
expect(
|
||||
config.source.darkStyleUrl,
|
||||
'https://train.example.org/map/style/dark.json',
|
||||
);
|
||||
expect(config.source.attribution, contains('Protomaps'));
|
||||
expect(config.source.tileKey, isNull);
|
||||
});
|
||||
|
||||
test("tiles that aren't HTTPS or lack a coordinate are never used", () {
|
||||
for (final url in [
|
||||
'http://train.example.org/tiles/{z}/{x}/{y}.mvt',
|
||||
'https://train.example.org/tiles/{z}/{x}.mvt',
|
||||
'https://{z}/{x}/{y}',
|
||||
'',
|
||||
]) {
|
||||
final config = mapConfigFrom({'MAP_TILES_URL': url});
|
||||
expect(config.tiles, isNull, reason: url);
|
||||
expect(config.source.name, 'OpenFreeMap', reason: url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:halkyon_train_server/src/map/train_style.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Train's style for the instance's own tiles (ADR 0030, D50). The app's
|
||||
/// tests read the copies this test keeps in its fixtures with
|
||||
/// vector_tile_renderer, which takes them without a warning; run with
|
||||
/// `UPDATE_STYLE_FIXTURES=1` after changing the style.
|
||||
void main() {
|
||||
const tiles = 'https://train.example.org/tiles/europe/{z}/{x}/{y}.mvt';
|
||||
|
||||
/// Protomaps' basemap's layers, schema v4.
|
||||
const schema = {
|
||||
'boundaries',
|
||||
'buildings',
|
||||
'earth',
|
||||
'landcover',
|
||||
'landuse',
|
||||
'places',
|
||||
'pois',
|
||||
'roads',
|
||||
'transit',
|
||||
'water',
|
||||
};
|
||||
|
||||
test('reads the tiles given, at zooms 0 to 15, with their credit', () {
|
||||
final style = trainStyle(tiles: tiles, dark: false);
|
||||
final source =
|
||||
(style['sources']! as Map<String, Object?>)['protomaps']!
|
||||
as Map<String, Object?>;
|
||||
expect(source['tiles'], [tiles]);
|
||||
expect(source['maxzoom'], 15);
|
||||
expect(source['attribution'], contains('OpenStreetMap'));
|
||||
});
|
||||
|
||||
test("each layer reads one of the schema's layers, its ids unique", () {
|
||||
for (final dark in [false, true]) {
|
||||
final layers =
|
||||
(trainStyle(tiles: tiles, dark: dark)['layers']! as List<Object?>)
|
||||
.cast<Map<String, Object?>>();
|
||||
expect({
|
||||
for (final layer in layers) layer['id'],
|
||||
}, hasLength(layers.length));
|
||||
for (final layer in layers.skip(1)) {
|
||||
expect(
|
||||
schema,
|
||||
contains(layer['source-layer']),
|
||||
reason: '${layer['id']}',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("names come in the app's language where the tiles have it, else as "
|
||||
'they are', () {
|
||||
String names(String? language) => jsonEncode(
|
||||
trainStyle(tiles: tiles, dark: false, language: language)['layers'],
|
||||
);
|
||||
expect(names('de'), contains('name:de'));
|
||||
expect(names('en'), contains('name:en'));
|
||||
expect(names('fr'), isNot(contains('name:')));
|
||||
expect(names(null), isNot(contains('name:')));
|
||||
});
|
||||
|
||||
test("the app's fixtures are the style as it is", () {
|
||||
final fixtures = {
|
||||
'train_style_light_de.json': trainStyle(
|
||||
tiles: tiles,
|
||||
dark: false,
|
||||
language: 'de',
|
||||
),
|
||||
'train_style_dark.json': trainStyle(tiles: tiles, dark: true),
|
||||
};
|
||||
for (final MapEntry(key: name, value: style) in fixtures.entries) {
|
||||
final file = File('../halkyon_train_flutter/test/fixtures/map/$name');
|
||||
final json = '${const JsonEncoder.withIndent(' ').convert(style)}\n';
|
||||
if (Platform.environment['UPDATE_STYLE_FIXTURES'] == '1') {
|
||||
file.writeAsStringSync(json);
|
||||
}
|
||||
expect(
|
||||
file.existsSync() ? file.readAsStringSync() : null,
|
||||
json,
|
||||
reason: '$name: run with UPDATE_STYLE_FIXTURES=1',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue