-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
#### Description Opengraph allows third party websites/applications to embed useful information about your page into their page. Currently basemaps serves a default image to all of these applications https://basemaps.linz.govt.nz/basemaps-card.jpeg which is a nice picture of Lyttleon rather than what is actually viewed. Adding a picture that is a preview of what the link displays is very useful with tools like slack, where often I take a screen shot of the page and then paste a link with the screenshot. Example Links #### Slack ![image](https://github.com/linz/basemaps/assets/1082761/49752243-e407-45f7-bc43-4b432d071c1e) #### LinkedIn ![image](https://github.com/linz/basemaps/assets/1082761/bd682b4b-c1f2-4a11-9190-8f1d456c0541) #### Intention Because the location of the page is stored only in a hash `#@...` this is never sent to the server so the server cannot update the `index.html`'s `og:image` location. This change modifies the default URL from `#@...` to `/@` which converts it from a hash to a path. The path is then sent to the server which processes it updates the latest index.html and serves it back to the client. The current lambda function does not have direct access to the `index.html` so it has to fetch it from `s3://` if this fetch fails, the user is just redirected back to the landing page. This could be improved in the future by letting the lambda contain a `index.html` but it breaks the deployment separation of the landing/tile server. #### Checklist *If not applicable, provide explanation of why.* - [x] Tests updated - [ ] Docs updated - [x] Issue linked in Title
- Loading branch information
Showing
20 changed files
with
555 additions
and
94 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import o from 'ospec'; | ||
import { LocationSlug } from '../slug.js'; | ||
|
||
o.spec('LocationUrl', () => { | ||
o('should encode lon lat', () => { | ||
o(LocationSlug.toSlug({ lat: -41.2778481, lon: 174.7763921, zoom: 8 })).equals(`@-41.2778481,174.7763921,z8`); | ||
o(LocationSlug.toSlug({ lat: -41.2778481, lon: 174.7763921, zoom: 10 })).equals(`@-41.2778481,174.7763921,z10`); | ||
o(LocationSlug.toSlug({ lat: -41.2778481, lon: 174.7763921, zoom: 18 })).equals(`@-41.2778481,174.7763921,z18`); | ||
o(LocationSlug.toSlug({ lat: -41.2778481, lon: 174.7763921, zoom: 20 })).equals(`@-41.2778481,174.7763921,z20`); | ||
o(LocationSlug.toSlug({ lat: -41.2778481, lon: 174.7763921, zoom: 22 })).equals(`@-41.2778481,174.7763921,z22`); | ||
|
||
// Common floating point fun | ||
o(LocationSlug.toSlug({ lat: 0.1 + 0.2, lon: -41.2778481, zoom: 22 })).equals(`@0.3000000,-41.2778481,z22`); | ||
}); | ||
|
||
o('should work from screenshot examples', () => { | ||
o(LocationSlug.fromSlug('#@-41.2890657,174.7769262,z16')).deepEquals({ | ||
lat: -41.2890657, | ||
lon: 174.7769262, | ||
zoom: 16, | ||
}); | ||
}); | ||
|
||
o('should round trip', () => { | ||
o(LocationSlug.toSlug({ lat: -41.277848, lon: 174.7763921, zoom: 8 })).equals(`@-41.2778480,174.7763921,z8`); | ||
o(LocationSlug.fromSlug(`@-41.2778480,174.7763921,z8`)).deepEquals({ lat: -41.277848, lon: 174.7763921, zoom: 8 }); | ||
}); | ||
|
||
o('should fail if zoom is outside of bounds', () => { | ||
o(LocationSlug.fromSlug('@-41.27785,174.77639,z0')).notEquals(null); | ||
|
||
o(LocationSlug.fromSlug('@-41.27785,174.77639,z-1')).equals(null); | ||
o(LocationSlug.fromSlug('@-41.27785,174.77639,z33')).equals(null); | ||
}); | ||
|
||
o('should fail if lat is outside of bounds', () => { | ||
o(LocationSlug.fromSlug('@-41.27785,174.77639,z1')).notEquals(null); | ||
|
||
o(LocationSlug.fromSlug('@-141.27785,174.77639,z1')).equals(null); | ||
o(LocationSlug.fromSlug('@141.27785,174.77639,z1')).equals(null); | ||
}); | ||
|
||
o('should fail if lon is outside of bounds', () => { | ||
o(LocationSlug.fromSlug('@-41.27785,174.77639,z1')).notEquals(null); | ||
o(LocationSlug.fromSlug('@-41.27785,274.77639,z1')).equals(null); | ||
o(LocationSlug.fromSlug('@-41.27785,-274.77639,z1')).equals(null); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,154 @@ | ||
import { TileMatrixSets } from './tms/index.js'; | ||
|
||
export interface LonLat { | ||
/** Latitude */ | ||
lat: number; | ||
/** Longitude */ | ||
lon: number; | ||
} | ||
|
||
export interface LonLatZoom extends LonLat { | ||
zoom: number; | ||
} | ||
|
||
export interface LocationQueryConfig { | ||
/** | ||
* Style name which is generally a `tileSetId` | ||
* | ||
* @example | ||
* "aerial" | ||
* "aerialhybrid" | ||
* "bounty-islands-satellite-2020-0.5m" | ||
*/ | ||
style: string; | ||
/** | ||
* TileMatrixSet identifier | ||
* | ||
* @example | ||
* "WebMercatorQuad" | ||
* "NZTM2000Quad" | ||
*/ | ||
tileMatrix: string; | ||
} | ||
|
||
export const LocationSlug = { | ||
/** | ||
* Number of decimal places to fix a decimal latitude/longitude | ||
* | ||
* 7 Decimal places is approx 0.011m of precision, | ||
* | ||
* Every decimal place is a factor of 10 precision | ||
* 5DP - 1.11m | ||
* 6DP - 0.11m | ||
* 7DP - 0.01m | ||
* | ||
*/ | ||
LonLatFixed: 7, | ||
|
||
/** Number of decimal places to fix a location zoom too */ | ||
ZoomFixed: 2, | ||
|
||
/** | ||
* Truncate a lat lon based on the zoom level | ||
* | ||
* @param loc location to truncate | ||
*/ | ||
truncateLatLon(loc: LonLatZoom): { lon: string; lat: string; zoom: string } { | ||
return { | ||
lon: loc.lon.toFixed(LocationSlug.LonLatFixed), | ||
lat: loc.lat.toFixed(LocationSlug.LonLatFixed), | ||
zoom: loc.zoom.toFixed(LocationSlug.ZoomFixed).replace(/\.0+$/, ''), // convert 8.00 into 8 | ||
}; | ||
}, | ||
|
||
/** | ||
* Encode a location into the format `@${lat},${lon},z${zoom}` | ||
* | ||
* This will truncate the lat, lon and zoom with {@link LocationSlug.truncateLatLon} | ||
* | ||
* @example | ||
* ``` | ||
* @-39.3042625,174.0794181,z22 | ||
* @-39.30426,174.07941,z13.5 | ||
* ``` | ||
*/ | ||
toSlug(loc: LonLatZoom): string { | ||
const fixed = LocationSlug.truncateLatLon(loc); | ||
return `@${fixed.lat},${fixed.lon},z${fixed.zoom}`; | ||
}, | ||
|
||
/** | ||
* Parsing zooms form a string in a format of `z14` or `14z` | ||
* | ||
* @param zoom string to parse zoom from | ||
*/ | ||
parseZoom(zoom: string | null): number | null { | ||
if (zoom == null || zoom === '') return null; | ||
if (zoom.startsWith('z')) return parseFloat(zoom.slice(1)); | ||
if (zoom.endsWith('z')) return parseFloat(zoom); | ||
return null; | ||
}, | ||
|
||
/** | ||
* Parse a location into a lat lon zoom pair | ||
* Validates that the location is withing the bounds | ||
* | ||
* - -90 <= lat <= 90 | ||
* - -190 <= lon <= 180 | ||
* - 0 <= zoom <= 32 | ||
* | ||
* @example | ||
* | ||
* ``` | ||
* /@-39.3042625,174.0794181,z22 | ||
* #@-39.30426,174.07941,z13.5 | ||
* ``` | ||
* | ||
* @returns location if parsed and validates, null otherwise | ||
*/ | ||
fromSlug(str: string): LonLatZoom | null { | ||
const output: Partial<LonLatZoom> = {}; | ||
const [latS, lonS, zoomS] = removeLocationPrefix(str).split(','); | ||
|
||
const lat = parseFloat(latS); | ||
if (isNaN(lat) || lat < -90 || lat > 90) return null; | ||
output.lat = lat; | ||
|
||
const lon = parseFloat(lonS); | ||
if (isNaN(lon) || lon < -180 || lon > 180) return null; | ||
output.lon = lon; | ||
|
||
const zoom = LocationSlug.parseZoom(zoomS); | ||
if (zoom == null || isNaN(zoom) || zoom < 0 || zoom > 32) return null; | ||
output.zoom = zoom; | ||
|
||
return output as LonLatZoom; | ||
}, | ||
|
||
/* | ||
* Parse common query string parameters and defaulting | ||
* `style` reads `?style=:style` then `?s=style` the `?i=:imageryId` defaults `aerial` | ||
* `tileMatrix` reads `?tileMatrix=:tileMatrixId` then `?p=:epsg|tileMatrix` then defaults to `WebMercatorQuad` | ||
*/ | ||
parseQuery(str: { get: (x: string) => string | null }): LocationQueryConfig { | ||
const tms = TileMatrixSets.find(str.get('tileMatrix') ?? str.get('p'), false); | ||
return { | ||
/** Style ordering, falling back onto the deprecated `?i=:imageryId if it exists otherwise a default of `aerial` */ | ||
style: str.get('style') ?? str.get('s') ?? str.get('i') ?? 'aerial', | ||
tileMatrix: tms?.identifier ?? 'WebMercatorQuad', | ||
}; | ||
}, | ||
}; | ||
|
||
/** | ||
* locations have a number of starting options, trim them out to make parsing easier | ||
* - full pathname `/@` | ||
* - hash path `#@` | ||
* - partial string `@` | ||
*/ | ||
function removeLocationPrefix(str: string): string { | ||
if (str.startsWith('/@')) return str.slice(2); | ||
if (str.startsWith('#@')) return str.slice(2); | ||
if (str.startsWith('@')) return str.slice(1); | ||
return str; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
94 changes: 94 additions & 0 deletions
94
packages/lambda-tiler/src/routes/__tests__/preview.index.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import { Env, LogConfig, V, fsa } from '@basemaps/shared'; | ||
import { LambdaAlbRequest, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda'; | ||
import { ALBEvent, Context } from 'aws-lambda'; | ||
import o from 'ospec'; | ||
|
||
import { loadAndServeIndexHtml } from '../preview.index.js'; | ||
import { LocationUrl } from '@basemaps/geo'; | ||
import { FsMemory } from '@chunkd/source-memory'; | ||
|
||
o.spec('/@*', async () => { | ||
o.specTimeout(1000); | ||
const baseRequest: ALBEvent = { | ||
requestContext: null as any, | ||
httpMethod: 'get', | ||
path: '/@-41.8900012,174.0492432,z5', | ||
body: null, | ||
isBase64Encoded: false, | ||
}; | ||
|
||
const fsMem = new FsMemory(); | ||
let lastLocation: string | undefined; | ||
o.beforeEach(() => { | ||
fsa.register('memory://', fsMem); | ||
lastLocation = process.env[Env.StaticAssetLocation]; | ||
}); | ||
o.afterEach(() => { | ||
if (lastLocation == null) delete process.env[Env.StaticAssetLocation]; | ||
else process.env[Env.StaticAssetLocation] = lastLocation; | ||
}); | ||
|
||
o('Should redirect on failure to load', async () => { | ||
const ctx: LambdaHttpRequest = new LambdaAlbRequest(baseRequest, {} as Context, LogConfig.get()); | ||
|
||
const res = await loadAndServeIndexHtml(ctx); | ||
o(res.status).equals(302); | ||
o(res.header('location')).equals('/?'); | ||
}); | ||
|
||
o('Should redirect with querystring on failure to load', async () => { | ||
const evt: ALBEvent = { ...baseRequest, queryStringParameters: { config: 'config-latest.json' } }; | ||
const ctx: LambdaHttpRequest = new LambdaAlbRequest(evt, {} as Context, LogConfig.get()); | ||
|
||
const res = await loadAndServeIndexHtml(ctx); | ||
o(res.status).equals(302); | ||
o(res.header('location')).equals('/?config=config-latest.json'); | ||
}); | ||
|
||
o('Should redirect with querystring and location on failure to load', async () => { | ||
const evt: ALBEvent = { ...baseRequest, queryStringParameters: { config: 'config-latest.json' } }; | ||
const loc = LocationUrl.fromSlug(evt.path); | ||
const ctx: LambdaHttpRequest = new LambdaAlbRequest(evt, {} as Context, LogConfig.get()); | ||
|
||
const res = await loadAndServeIndexHtml(ctx, loc); | ||
o(res.status).equals(302); | ||
o(res.header('location')).equals('/?config=config-latest.json#@-41.8900012,174.0492432,z5'); | ||
}); | ||
|
||
o('should redirect on failure to load index.html', async () => { | ||
const ctx: LambdaHttpRequest = new LambdaAlbRequest(baseRequest, {} as Context, LogConfig.get()); | ||
process.env[Env.StaticAssetLocation] = 'memory://assets/'; | ||
|
||
const res = await loadAndServeIndexHtml(ctx); | ||
o(res.status).equals(302); | ||
}); | ||
|
||
o('should redirect with new tags!', async () => { | ||
const ctx = new LambdaAlbRequest(baseRequest, {} as Context, LogConfig.get()); | ||
process.env[Env.StaticAssetLocation] = 'memory://assets/'; | ||
|
||
const indexHtml = V('html', [ | ||
V('head', [ | ||
V('meta', { property: 'og:title', content: 'LINZ Basemaps' }), | ||
V('meta', { property: 'og:image', content: '/basemaps-card.jepg' }), | ||
V('meta', { name: 'viewport' }), | ||
]), | ||
]).toString(); | ||
|
||
await fsa.write('memory://assets/index.html', indexHtml); | ||
|
||
// Pass back the body un altered | ||
const res = await loadAndServeIndexHtml(ctx); | ||
o(getBody(res)?.toString()).equals(indexHtml); | ||
|
||
// Replace og:title with a <fake tag /> | ||
const resB = await loadAndServeIndexHtml(ctx, null, new Map([['og:title', '<fake tag />']])); | ||
o(getBody(resB)?.toString().includes('<fake tag />')).equals(true); | ||
}); | ||
}); | ||
|
||
function getBody(res: LambdaHttpResponse): Buffer | null { | ||
if (res._body == null) return null; | ||
if (res.isBase64Encoded) return Buffer.from(res._body as string, 'base64'); | ||
return Buffer.from(res._body as string); | ||
} |
Oops, something went wrong.