Skip to content

Commit

Permalink
feat: add og:image preview to all basemaps links BM-264 (#2925)
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
blacha authored Aug 31, 2023
1 parent 7b7cc1d commit de00528
Show file tree
Hide file tree
Showing 20 changed files with 555 additions and 94 deletions.
22 changes: 21 additions & 1 deletion packages/_infra/src/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export class EdgeStack extends cdk.Stack {

lambdaUrlSource(lambdaUrl: string): cf.SourceConfiguration {
const trimmedUrl = new URL(lambdaUrl); // LambdaURLS include https:// and a trailing /

return {
customOriginSource: {
domainName: trimmedUrl.hostname,
Expand All @@ -101,7 +102,26 @@ export class EdgeStack extends cdk.Stack {
forwardedValues: {
/** Forward all query strings but do not use them for caching */
queryString: true,
queryStringCacheKeys: ['config', 'exclude', 'date[before]', 'date[after]'].map(encodeURIComponent),
queryStringCacheKeys: ['config', 'exclude'].map(encodeURIComponent),
},
lambdaFunctionAssociations: [],
},
{
pathPattern: '/@*',
allowedMethods: cf.CloudFrontAllowedMethods.ALL,
forwardedValues: {
/** Forward all query strings but do not use them for caching */
queryString: true,
queryStringCacheKeys: [
'config',
'exclude',
'tileMatrix',
'style',
// Deprecated single character query params for style and projection
's',
'p',
'i', // ?i=:imageryId is deprecated and should be removed at some point
].map(encodeURIComponent),
},
lambdaFunctionAssociations: [],
},
Expand Down
4 changes: 4 additions & 0 deletions packages/_infra/src/serve/lambda.tiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export class LambdaTiler extends Construct {
AWS_NODEJS_CONNECTION_REUSE_ENABLED: '1',
};

if (props.staticBucketName) {
environment[Env.StaticAssetLocation] = `s3://${props.staticBucketName}/`;
}

const code = lambda.Code.fromAsset(CODE_PATH);

/**
Expand Down
48 changes: 48 additions & 0 deletions packages/geo/src/__tests__/slug.test.ts
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);
});
});
1 change: 1 addition & 0 deletions packages/geo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export { TileId } from './tile.js';

export * from './proj/projection.js';
export { ProjectionLoader } from './proj/projection.loader.js';
export { LocationSlug as LocationUrl, LonLat, LonLatZoom } from './slug.js';
154 changes: 154 additions & 0 deletions packages/geo/src/slug.ts
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;
}
7 changes: 7 additions & 0 deletions packages/geo/src/tile.matrix.set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ export class TileMatrixSet {
return this.zooms.length - 1;
}

/**
* Unique identifier of the TileMatrix {@link TileMatrixSetType.identifier}
*
* @example
* "WebMercatorQuad"
* "NZTM2000Quad"
*/
get identifier(): string {
return this.def.identifier;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/lambda-tiler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { NotFound, OkResponse } from './util/response.js';
import { CoSources } from './util/source.cache.js';
import { St } from './util/source.tracer.js';
import { tilePreviewGet } from './routes/preview.js';
import { previewIndexGet } from './routes/preview.index.js';

export const handler = lf.http(LogConfig.get());

Expand Down Expand Up @@ -96,6 +97,8 @@ handler.router.get('/v1/tiles/:tileSet/:tileMatrix/:z/:x/:y.:tileType', tileXyzG

// Preview
handler.router.get('/v1/preview/:tileSet/:tileMatrix/:z/:lon/:lat', tilePreviewGet);
handler.router.get('/v1/@:location', previewIndexGet);
handler.router.get('/@:location', previewIndexGet);

// Attribution
handler.router.get('/v1/tiles/:tileSet/:tileMatrix/attribution.json', tileAttributionGet);
Expand Down
94 changes: 94 additions & 0 deletions packages/lambda-tiler/src/routes/__tests__/preview.index.test.ts
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);
}
Loading

0 comments on commit de00528

Please sign in to comment.