Skip to content

Commit

Permalink
Merge pull request #48 from openstreetmap-polska/dev
Browse files Browse the repository at this point in the history
Release to main
  • Loading branch information
Zaczero authored Feb 10, 2024
2 parents 9b7f1b7 + 19dc8e9 commit a4eaa6d
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 108 deletions.
4 changes: 1 addition & 3 deletions api/v1/countries.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import anyio
from anyio.streams.memory import MemoryObjectSendStream
from fastapi import APIRouter, Path, Request, Response
from fastapi import APIRouter, Path, Response
from shapely.geometry import mapping

from middlewares.cache_middleware import configure_cache
Expand All @@ -22,7 +22,6 @@ async def _count_aed_in_country(country: Country, aed_state: AEDState, send_stre
@router.get('/names')
@configure_cache(timedelta(hours=1), stale=timedelta(days=7))
async def get_names(
request: Request,
country_state: CountryStateDep,
aed_state: AEDStateDep,
language: str | None = None,
Expand Down Expand Up @@ -66,7 +65,6 @@ def limit_country_names(names: dict[str, str]):
@router.get('/{country_code}.geojson')
@configure_cache(timedelta(hours=1), stale=timedelta(seconds=0))
async def get_geojson(
request: Request,
response: Response,
country_code: Annotated[str, Path(min_length=2, max_length=5)],
country_state: CountryStateDep,
Expand Down
59 changes: 37 additions & 22 deletions api/v1/node.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import re
from datetime import datetime
from datetime import datetime, timedelta
from urllib.parse import quote_plus

from fastapi import APIRouter, HTTPException
from pytz import timezone
from tzfpy import get_tz

from middlewares.cache_middleware import configure_cache
from models.lonlat import LonLat
from states.aed_state import AEDStateDep
from states.photo_state import PhotoStateDep
Expand All @@ -30,42 +31,56 @@ def _get_timezone(lonlat: LonLat) -> tuple[str | None, str | None]:
return timezone_name, timezone_offset


@router.get('/node/{node_id}')
async def get_node(node_id: str, aed_state: AEDStateDep, photo_state: PhotoStateDep):
aed = await aed_state.get_aed_by_id(node_id)

if aed is None:
raise HTTPException(404, f'Node {node_id!r} not found')

timezone_name, timezone_offset = _get_timezone(aed.position)
timezone_dict = {
'@timezone_name': timezone_name,
'@timezone_offset': timezone_offset,
}

image_url = aed.tags.get('image', '')
async def _get_image_data(tags: dict[str, str], photo_state: PhotoStateDep) -> dict:
image_url: str = tags.get('image', '')

if (
image_url
and (photo_id_match := photo_id_re.search(image_url))
and (photo_id := photo_id_match.group('id'))
and (photo_info := await photo_state.get_photo_by_id(photo_id))
):
photo_dict = {
return {
'@photo_id': photo_info.id,
'@photo_url': f'/api/v1/photos/view/{photo_info.id}.webp',
}
elif image_url:
photo_dict = {

if image_url:
return {
'@photo_id': None,
'@photo_url': f'/api/v1/photos/proxy/{quote_plus(image_url)}',
'@photo_url': f'/api/v1/photos/proxy/direct/{quote_plus(image_url)}',
}
else:
photo_dict = {

wikimedia_commons: str = tags.get('wikimedia_commons', '')

if wikimedia_commons:
return {
'@photo_id': None,
'@photo_url': None,
'@photo_url': f'/api/v1/photos/proxy/wikimedia-commons/{quote_plus(wikimedia_commons)}',
}

return {
'@photo_id': None,
'@photo_url': None,
}


@router.get('/node/{node_id}')
@configure_cache(timedelta(minutes=1), stale=timedelta(minutes=5))
async def get_node(node_id: str, aed_state: AEDStateDep, photo_state: PhotoStateDep):
aed = await aed_state.get_aed_by_id(node_id)

if aed is None:
raise HTTPException(404, f'Node {node_id!r} not found')

photo_dict = await _get_image_data(aed.tags, photo_state)

timezone_name, timezone_offset = _get_timezone(aed.position)
timezone_dict = {
'@timezone_name': timezone_name,
'@timezone_offset': timezone_offset,
}

return {
'version': 0.6,
'copyright': 'OpenStreetMap and contributors',
Expand Down
60 changes: 40 additions & 20 deletions api/v1/photos.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import magic
import orjson
from bs4 import BeautifulSoup
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse
from feedgen.feed import FeedGenerator
Expand All @@ -21,23 +22,10 @@
router = APIRouter(prefix='/photos')


@router.get('/view/{id}.webp')
@configure_cache(timedelta(days=365), stale=timedelta(days=365))
async def view(request: Request, id: str, photo_state: PhotoStateDep) -> FileResponse:
info = await photo_state.get_photo_by_id(id)

if info is None:
raise HTTPException(404, f'Photo {id!r} not found')

return FileResponse(info.path)


@router.get('/proxy/{url_encoded:path}')
@configure_cache(timedelta(days=7), stale=timedelta(days=7))
async def proxy(request: Request, url_encoded: str) -> FileResponse:
async def _fetch_image(url: str) -> tuple[bytes, str]:
# NOTE: ideally we would verify whether url is not a private resource
async with get_http_client() as http:
r = await http.get(unquote_plus(url_encoded))
r = await http.get(url)
r.raise_for_status()

# Early detection of unsupported types
Expand All @@ -58,7 +46,43 @@ async def proxy(request: Request, url_encoded: str) -> FileResponse:
if content_type not in IMAGE_CONTENT_TYPES:
raise HTTPException(500, f'Unsupported file type {content_type!r}, must be one of {IMAGE_CONTENT_TYPES}')

return Response(content=file, media_type=content_type)
return file, content_type


@router.get('/view/{id}.webp')
@configure_cache(timedelta(days=365), stale=timedelta(days=365))
async def view(id: str, photo_state: PhotoStateDep) -> FileResponse:
info = await photo_state.get_photo_by_id(id)

if info is None:
raise HTTPException(404, f'Photo {id!r} not found')

return FileResponse(info.path)


@router.get('/proxy/direct/{url_encoded:path}')
@configure_cache(timedelta(days=7), stale=timedelta(days=7))
async def proxy_direct(url_encoded: str) -> FileResponse:
file, content_type = await _fetch_image(unquote_plus(url_encoded))
return Response(file, media_type=content_type)


@router.get('/proxy/wikimedia-commons/{path_encoded:path}')
@configure_cache(timedelta(days=7), stale=timedelta(days=7))
async def proxy_wikimedia_commons(path_encoded: str) -> FileResponse:
async with get_http_client() as http:
url = f'https://commons.wikimedia.org/wiki/{unquote_plus(path_encoded)}'
r = await http.get(url)
r.raise_for_status()

bs = BeautifulSoup(r.text, 'lxml')
og_image = bs.find('meta', property='og:image')
if not og_image:
raise HTTPException(404, 'Missing og:image meta tag')

image_url = og_image['content']
file, content_type = await _fetch_image(image_url)
return Response(file, media_type=content_type)


@router.post('/upload')
Expand All @@ -76,12 +100,10 @@ async def upload(

if file_license not in accept_licenses:
raise HTTPException(400, f'Unsupported license {file_license!r}, must be one of {accept_licenses}')

if file.size <= 0:
raise HTTPException(400, 'File must not be empty')

content_type = magic.from_buffer(file.file.read(2048), mime=True)

if content_type not in IMAGE_CONTENT_TYPES:
raise HTTPException(400, f'Unsupported file type {content_type!r}, must be one of {IMAGE_CONTENT_TYPES}')

Expand All @@ -94,13 +116,11 @@ async def upload(
raise HTTPException(400, 'OAuth2 credentials must contain an access_token field')

aed = await aed_state.get_aed_by_id(node_id)

if aed is None:
raise HTTPException(404, f'Node {node_id!r} not found, perhaps it is not an AED?')

osm = OpenStreetMap(oauth2_credentials_)
osm_user = await osm.get_authorized_user()

if osm_user is None:
raise HTTPException(401, 'OAuth2 credentials are invalid')

Expand Down
11 changes: 9 additions & 2 deletions middlewares/cache_middleware.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import functools
from contextvars import ContextVar
from datetime import timedelta

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp

_request_context = ContextVar('Request_context')


def make_cache_control(max_age: timedelta, stale: timedelta):
return f'public, max-age={int(max_age.total_seconds())}, stale-while-revalidate={int(stale.total_seconds())}'
Expand All @@ -17,7 +20,11 @@ def __init__(self, app: ASGIApp, max_age: timedelta, stale: timedelta):
self.stale = stale

async def dispatch(self, request: Request, call_next):
response = await call_next(request)
token = _request_context.set(request)
try:
response = await call_next(request)
finally:
_request_context.reset(token)

if request.method in ('GET', 'HEAD') and 200 <= response.status_code < 300:
try:
Expand All @@ -40,7 +47,7 @@ def configure_cache(max_age: timedelta, stale: timedelta):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
request: Request = kwargs['request']
request: Request = _request_context.get()
request.state.max_age = max_age
request.state.stale = stale
return await func(*args, **kwargs)
Expand Down
Loading

0 comments on commit a4eaa6d

Please sign in to comment.