Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release to main #48

Merged
merged 2 commits into from
Feb 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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