obs-portal/api/obs/api/routes/tiles.py
2022-07-28 22:05:31 +02:00

131 lines
3.9 KiB
Python

from gzip import decompress
from sqlite3 import connect
from datetime import datetime, time, timedelta
from typing import Optional, Tuple
import dateutil.parser
from sanic.exceptions import Forbidden, InvalidUsage
from sanic.response import raw
from sqlalchemy import select, text
from sqlalchemy.sql.expression import table, column
from obs.api.app import app
def get_tile(filename, zoom, x, y):
"""
Inspired by:
https://github.com/TileStache/TileStache/blob/master/TileStache/MBTiles.py
"""
db = connect(filename)
db.text_factory = bytes
fmt = db.execute("SELECT value FROM metadata WHERE name='format'").fetchone()[0]
if fmt != b"pbf":
raise ValueError("mbtiles file is in wrong format: %s" % fmt)
content = db.execute(
"SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=?",
(zoom, x, (2**zoom - 1) - y),
).fetchone()
return content and content[0] or None
def round_date(date, to="weeks", up=False):
if to != "weeks":
raise ValueError(f"cannot round to {to}")
midnight = time(0, 0, 0, 0)
start_of_day = date.date() # ignore time
weekday = date.weekday()
is_rounded = date.time() == midnight and weekday == 0
if is_rounded:
return date
if up:
return datetime.combine(start_of_day + timedelta(days=7 - weekday), midnight)
else:
return datetime.combine(start_of_day - timedelta(days=weekday), midnight)
# regenerate approx. once each day
TILE_CACHE_MAX_AGE = 3600 * 24
def get_filter_options(
req,
) -> Tuple[Optional[str], Optional[datetime], Optional[datetime]]:
"""
Returns parsed, validated and normalized options for filtering map data, a
tuple of
* user_id (str|None)
* start (datetime|None)
* end (datetime|None)
"""
user_id = None
username = req.ctx.get_single_arg("user", default=None)
if username is not None:
if req.ctx.user is None or req.ctx.user.username != username:
raise Forbidden()
user_id = req.ctx.user.id
parse_date = lambda s: dateutil.parser.parse(s)
start = req.ctx.get_single_arg("start", default=None, convert=parse_date)
end = req.ctx.get_single_arg("end", default=None, convert=parse_date)
start = round_date(start, to="weeks", up=False) if start else None
end = round_date(end, to="weeks", up=True) if end else None
if start is not None and end is not None and start >= end:
raise InvalidUsage(
"end date must be later than start date (note: dates are rounded to weeks)"
)
return user_id, start, end
@app.route(r"/tiles/<zoom:int>/<x:int>/<y:(\d+)\.pbf>")
async def tiles(req, zoom: int, x: int, y: str):
if app.config.get("TILES_FILE"):
tile = get_tile(req.app.config.TILES_FILE, int(zoom), int(x), int(y))
else:
user_id, start, end = get_filter_options(req)
tile = await req.ctx.db.scalar(
text(
f"select data from getmvt(:zoom, :x, :y, :user_id, :min_time, :max_time) as b(data, key);"
).bindparams(
zoom=int(zoom),
x=int(x),
y=int(y),
user_id=user_id,
min_time=start,
max_time=end,
)
)
gzip = "gzip" in req.headers["accept-encoding"]
headers = {}
headers["Vary"] = "Accept-Encoding"
if req.app.config.DEBUG:
headers["Cache-Control"] = "no-cache"
else:
headers["Cache-Control"] = f"public, max-age={TILE_CACHE_MAX_AGE}"
# The tiles in the mbtiles file are gzip-compressed already, so we
# serve them actually as-is, and only decompress them if the browser
# doesn't accept gzip
if gzip:
headers["Content-Encoding"] = "gzip"
if not gzip:
tile = decompress(tile)
return raw(tile, content_type="application/x-protobuf", headers=headers)