blog: Protomaps Supabase Storage Blog & Example (#27231)

* chore: add example.

* blog: host protomaps on supabase storage.

* chore: some cal blogpost nits.

* chore: add video to top.
This commit is contained in:
Thor 雷神 Schaeff authored and GitHub committed 2024-06-19 21:07:56 +08:00
1 parent 22b76fecaf
commit b427907a24
11 files changed
+508 -1

No files matched your search

@@ -206,7 +206,7 @@ And when using the [Vercel Deploy Button](https://vercel.com/new/clone?repositor
## Contributing to open source
Both Cal.com and Supabase are on a mission to create open source software, therefore this new platform starter kit is of course also open-source, allowing you to spin up your own marketplace which convenient scheduling in minutes! Of course this also means that you are very welcome to contribute additional features to the starter kit! You can [find the repository on GitHub](https://github.com/calcom/platform-starter-kit)!
Both Cal.com and Supabase are on a mission to create open source software, therefore this new platform starter kit is of course also open-source, allowing you to spin up your own marketplace with convenient scheduling in minutes! Of course this also means that you are very welcome to contribute additional features to the starter kit! You can [find the repository on GitHub](https://github.com/calcom/platform-starter-kit)!
## Resources
@@ -0,0 +1,198 @@
---
title: 'Self-host Maps with Protomaps and Supabase Storage'
description: 'Host Protomaps PMTiles on Supabase Storage and render them with MapLibre GL.'
author: bdon,thor_schaeff
image: protomaps_storage/protomaps_storage_og.png
thumb: protomaps_storage/protomaps_storage_og.png
categories:
- developers
tags:
- storage
- postgis
- maps
date: '2024-06-19'
toc_depth: 3
---
<Admonition>
[Do you prefer the audio-visual learning? Watch the video guide!](https://supabase.link/protomaps-storage-yt)
</Admonition>
[Protomaps](https://protomaps.com/) is an open source map of the world, deployable as a single static file on [Supabase Storage](/storage).
In this tutorial, you will learn to
- Use Protomaps to excract an area into a static PMTiles file.
- Upload the PMTiles file to Supabase Storage.
- Use MapLibre to render the Map onto a Web Page.
- Use Supabase Edge Functions to restrict File Access.
## Excract an area into a static PMTiles file
Protomaps provides a [`pmtiles` CLI](https://docs.protomaps.com/guide/getting-started#_1-install-the-cli) that can be used to cut out certain areas from the world map and compress those into a single static file.
For example, we can [extract a small area](https://docs.protomaps.com/guide/getting-started#_3-extract-any-area) around Utrecht in the Netherlands like this:
```bash
pmtiles extract https://build.protomaps.com/20240618.pmtiles my_area.pmtiles --bbox=5.068050,52.112086,5.158424,52.064140
```
Note: make sure to update the date to the latest daily build!
This will create a `my_area.pmtiles` file which you can upload to Supabase Storage.
## Upload the PMTiles file to Supabase Storage
In your [Supabase Dashboard](https://supabase.com/dashboard/project/_/storage/buckets) navigate to `Storage` and click "New Bucket" and create a new public bucket called `public-maps`.
Upload the `my_area.pmtiles` file created earlier to your public bucket. Once uploaded, click the file and tap "Get URL".
Supabase Storage supports the required [HTTP Range Requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests) out of the box, allowing you to use the public storage URL directly from your maps client.
## Use MapLibre to render the Map
PMTiles easily works with both [MapLibre GL](https://docs.protomaps.com/pmtiles/maplibre) and [Leaflet](https://docs.protomaps.com/pmtiles/leaflet). In our example we wil use [MapLibre GL](https://maplibre.org/maplibre-gl-js/docs/), which is a TypeScript library that uses WebGL to render interactive maps from vector tiles in a browser.
This is a vanilla JS example which uses CDN releases of the libraries. You can very easily adapt it to work with React as well, for example using the [react-map-gl](https://visgl.github.io/react-map-gl/) library.
```html index.html
<html>
<head>
<title>Overture Places</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
href="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css"
crossorigin="anonymous"
/>
<script
src="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js"
crossorigin="anonymous"
></script>
<script src="https://unpkg.com/protomaps-themes-base@2.0.0-alpha.5/dist/index.js"></script>
<script src="https://unpkg.com/pmtiles@3.0.6/dist/pmtiles.js"></script>
<style>
body {
margin: 0;
}
#map {
height: 100%;
width: 100%;
}
</style>
</head>
<body>
<div id="map"></div>
<script type="text/javascript">
// Add the PMTiles Protocol:
let protocol = new pmtiles.Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
// Load the Map tiles directly from Supabase Storage:
const map = new maplibregl.Map({
hash: true,
container: 'map',
style: {
version: 8,
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
sources: {
protomaps: {
attribution:
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
type: 'vector',
url: 'pmtiles://https://<your-project-ref>.supabase.co/storage/v1/object/public/public-maps/my_area.pmtiles',
},
},
layers: protomaps_themes_base.default('protomaps', 'dark'),
},
})
</script>
</body>
</html>
```
## Use Supabase Edge Functions to restrict Access
A public Supabase Storage bucket allows access from any origin, which might not be ideal for your use case. At the time of writing, you're not able to modify the CORS settings for Supabase Storage buckets, however you can utilize [Supabase Edge Functions](https://supabase.com/docs/guides/functions) to restrict access to your PMTiles files, allowing you to even pair it with [Supabase Auth](https://supabase.com/docs/guides/auth) to restrict access to certain users for example.
In your Supabase Dashboard, create a new private storage bucket called `maps-private` and upload your `my_area.pmtiles` file there. Files in private buckets can only be accessed through either a short-lived signed URL, or by passing the secret service role key as an authorization header. Since our Edge Function is a secure server-side environment, we can utilize the latter approach here.
Using the [Supabase CLI](https://github.com/supabase/cli), create a new Edge Function by running `supabase functions new `maps-private`, then add the following code to your newly created function:
```ts supabase/functions/maps-private/index.ts
const ALLOWED_ORIGINS = ['http://localhost:8000']
const corsHeaders = {
'Access-Control-Allow-Origin': ALLOWED_ORIGINS.join(','),
'Access-Control-Allow-Headers':
'authorization, x-client-info, apikey, content-type, range, if-match',
'Access-Control-Expose-Headers': 'range, accept-ranges, etag',
'Access-Control-Max-Age': '300',
}
Deno.serve((req) => {
// This is needed if you're planning to invoke your function from a browser.
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
// Check origin
const origin = req.headers.get('Origin')
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
return new Response('Not Allowed', { status: 405 })
}
const reqUrl = new URL(req.url)
const url = `${Deno.env.get('SUPABASE_URL')}/storage/v1/object/authenticated${reqUrl.pathname}`
const { method, headers } = req
// Add Auth header
const modHeaders = new Headers(headers)
modHeaders.append('authorization', `Bearer ${Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!}`)
return fetch(url, { method, headers: modHeaders })
})
```
If you want to further restrict access based on authenticated users, you can pair your Edge Function with Supabase Auth as shown in [this example](/docs/guides/functions/auth#fetching-the-user).
Lastly, we need to deploy our Edge Function to Supabase by running `supabase functions deploy maps-private --no-verify-jwt`. Note that the [`--no-verify-jwt` flag](https://supabase.com/docs/reference/cli/supabase-functions-deploy) is required if you want to allow public access from your website without any Supabase Auth User.
Now we can simply replace the public storage URL with our Egde Functions URL to proxy the range requests to our private bucket:
```js index.html
// ...
const map = new maplibregl.Map({
hash: true,
container: 'map',
style: {
version: 8,
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
sources: {
protomaps: {
attribution:
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
type: 'vector',
url: 'pmtiles://https://<project_ref>.supabase.co/functions/v1/maps-private/my_area.pmtiles',
},
},
layers: protomaps_themes_base.default('protomaps', 'dark'),
},
})
// ...
```
Now go ahead and serve your `index.html` file, for example via Python SimpleHTTPServer: `python3 -m http.server` and admire your beautiful map on [localhost:8000](http://localhost:8000/)!
## Conclusion
Protomaps is a fantastic open source project that allows you to host your own Google Maps alternative on Supabase Storage. You can further extend this this powerful PostGIS capabilities to programmatically generate MapBoc Vector Tiles which we will explore in the next post in this series. So make sure you subscribe to our [Twitter](https://x.com/supabase) and [YouTube](https://www.youtube.com/@Supabase) channels to not miss out! See you then!
## More Supabase
- [Watch the video guide](https://youtu.be/l7QBpiLRwJc)
- [Find the code](https://github.com/supabase/supabase/tree/master/examples/storage/protomaps)
- [Getting started with PostGIS video](https://youtu.be/MWfB0t5u3V0)
- [PostGIS docs guide](/docs/guides/database/extensions/postgis)
+8
View File
@@ -454,6 +454,14 @@
"author_url": "https://github.com/chrischandler",
"author_image_url": "https://github.com/chrischandler.png"
},
{
"author_id": "bdon",
"author": "Brandon Liu",
"username": "bdon",
"position": "Guest Author",
"author_url": "https://github.com/bdon",
"author_image_url": "https://github.com/bdon.png"
},
{
"author_id": "p6l-richard",
"author": "Richard Poelderl",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+31
View File
@@ -0,0 +1,31 @@
# Self-Host Maps on Supabase Storage with Protomaps
## Create a static PMTiles Map file
Follow the instructions in the [Protomaps docs](https://docs.protomaps.com/guide/getting-started) to extract a `my_area.pmtiles` file.
## Upload to Supabase Storage
1. Create a new private bucket called `maps-private`.
2. Upload your `my_area.pmtiles` file there.
Take note of the [file size limits](https://supabase.com/docs/guides/storage/uploads/file-limits#global-file-size) depending on your project tier.
## Proxy through Edge Functions
You can use [Supabase Edge Functions](https://supabase.com/edge-functions) to set up fine grained access controls. Use the [/supabase/functions/maps-private/index.ts](/supabase/functions/maps-private/index.ts).
You can also use Edge Functions with Supabase Auth JWTs to only render Maps for authenticated users for example. [Read the docs](https://supabase.com/docs/guides/functions/auth).
1. Deploy the function to your Supabase project: `supabase functions deploy maps-private --no-verify-jwt`.
2. Update the `protomaps.url` in the [index.html](/index.html) file.
## Start simple web server
You can use python to serve the `index.html` file:
```bash
python3 -m http.server
```
Now navigate to http://localhost:8000/ to see your beauiful Map!
+52
View File
@@ -0,0 +1,52 @@
<html>
<head>
<title>Overture Places</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
href="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css"
crossorigin="anonymous"
/>
<script
src="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js"
crossorigin="anonymous"
></script>
<script src="https://unpkg.com/protomaps-themes-base@2.0.0-alpha.5/dist/index.js"></script>
<script src="https://unpkg.com/pmtiles@3.0.6/dist/pmtiles.js"></script>
<style>
body {
margin: 0;
}
#map {
height: 100%;
width: 100%;
}
</style>
</head>
<body>
<div id="map"></div>
<script type="text/javascript">
let protocol = new pmtiles.Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
const map = new maplibregl.Map({
hash: true,
container: 'map',
style: {
version: 8,
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
sources: {
protomaps: {
attribution:
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
type: 'vector',
url: 'pmtiles://https://<project_ref>.supabase.co/functions/v1/maps-private/my_area.pmtiles',
},
},
layers: protomaps_themes_base.default('protomaps', 'dark'),
},
})
</script>
</body>
</html>
@@ -0,0 +1,4 @@
# Supabase
.branches
.temp
.env
@@ -0,0 +1,178 @@
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "protomaps"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` is always included.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request. `public` is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
[storage.image_transformation]
enabled = true
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = true
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }} ."
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
[edge_runtime]
enabled = true
# Configure one of the supported request policies: `oneshot`, `per_worker`.
# Use `oneshot` for hot reload, or `per_worker` for load testing.
policy = "oneshot"
inspector_port = 8083
[analytics]
enabled = false
port = 54327
vector_port = 54328
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
@@ -0,0 +1,36 @@
const ALLOWED_ORIGINS = ["http://localhost:8000"];
const corsHeaders = {
"Access-Control-Allow-Origin": ALLOWED_ORIGINS.join(","),
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, range, if-match",
"Access-Control-Expose-Headers": "range, accept-ranges, etag",
"Access-Control-Max-Age": "300",
};
Deno.serve((req) => {
// This is needed if you're planning to invoke your function from a browser.
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
// Check origin
const origin = req.headers.get("Origin");
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
return new Response("Not Allowed", { status: 405 });
}
const reqUrl = new URL(req.url);
const url = `${
Deno.env.get("SUPABASE_URL")
}/storage/v1/object/authenticated${reqUrl.pathname}`;
const { method, headers } = req;
// Add Auth header
const modHeaders = new Headers(headers);
modHeaders.append(
"authorization",
`Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!}`,
);
return fetch(url, { method, headers: modHeaders });
});
Whitespace-only changes.