Skip to content

Commit

Permalink
Photos proxy
Browse files Browse the repository at this point in the history
  • Loading branch information
Zaczero committed Jan 15, 2024
1 parent 3c959b7 commit e6517d6
Show file tree
Hide file tree
Showing 5 changed files with 52 additions and 10 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"args": [
"main:app",
"--workers",
"8",
"1",
],
"jinja": true,
"justMyCode": true,
Expand Down
11 changes: 9 additions & 2 deletions api/v1/node.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import re
from datetime import datetime
from urllib.parse import quote_plus

from fastapi import APIRouter, HTTPException
from pytz import timezone
Expand Down Expand Up @@ -42,9 +43,10 @@ async def get_node(node_id: str, aed_state: AEDStateDep, photo_state: PhotoState
'@timezone_offset': timezone_offset,
}

# TODO: support other image sources
image_url = aed.tags.get('image', '')

if (
(image_url := aed.tags.get('image', ''))
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))
Expand All @@ -53,6 +55,11 @@ async def get_node(node_id: str, aed_state: AEDStateDep, photo_state: PhotoState
'@photo_id': photo_info.id,
'@photo_url': f'/api/v1/photos/view/{photo_info.id}.webp',
}
elif image_url:
photo_dict = {
'@photo_id': None,
'@photo_url': f'/api/v1/photos/proxy/{quote_plus(image_url)}',
}
else:
photo_dict = {
'@photo_id': None,
Expand Down
38 changes: 35 additions & 3 deletions api/v1/photos.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
from datetime import UTC, datetime, timedelta
from io import BytesIO
from typing import Annotated
from urllib.parse import unquote_plus

import magic
import orjson
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse
from feedgen.feed import FeedGenerator

from config import IMAGE_CONTENT_TYPES, REMOTE_IMAGE_MAX_FILE_SIZE
from middlewares.cache_middleware import configure_cache
from openstreetmap import OpenStreetMap, osm_user_has_active_block
from osm_change import update_node_tags_osm_change
from states.aed_state import AEDStateDep
from states.photo_report_state import PhotoReportStateDep
from states.photo_state import PhotoStateDep
from utils import get_http_client

router = APIRouter(prefix='/photos')

Expand All @@ -28,6 +32,35 @@ async def view(request: Request, id: str, photo_state: PhotoStateDep) -> FileRes
return FileResponse(info.path)


@router.get('/proxy/{url_encoded}')
@configure_cache(timedelta(days=7), stale=timedelta(days=7))
async def proxy(request: Request, url_encoded: str) -> FileResponse:
# 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.raise_for_status()

# Early detection of unsupported types
content_type = r.headers.get('Content-Type')
if content_type and content_type not in IMAGE_CONTENT_TYPES:
raise HTTPException(500, f'Unsupported file type {content_type!r}, must be one of {IMAGE_CONTENT_TYPES}')

with BytesIO() as buffer:
async for chunk in r.aiter_bytes(chunk_size=1024 * 1024):
buffer.write(chunk)
if buffer.tell() > REMOTE_IMAGE_MAX_FILE_SIZE:
raise HTTPException(500, f'File is too large, max allowed size is {REMOTE_IMAGE_MAX_FILE_SIZE} bytes')

file = buffer.getvalue()

# Check if file type is supported
content_type = magic.from_buffer(file[:2048], mime=True)
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)


@router.post('/upload')
async def upload(
request: Request,
Expand All @@ -48,10 +81,9 @@ async def upload(
raise HTTPException(400, 'File must not be empty')

content_type = magic.from_buffer(file.file.read(2048), mime=True)
accept_content_types = ('image/jpeg', 'image/png', 'image/webp')

if content_type not in accept_content_types:
raise HTTPException(400, f'Unsupported file type {content_type!r}, must be one of {accept_content_types}')
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}')

try:
oauth2_credentials_ = orjson.loads(oauth2_credentials)
Expand Down
5 changes: 4 additions & 1 deletion config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pyproj import Transformer

NAME = 'openaedmap-backend'
VERSION = '2.4'
VERSION = '2.5'
VERSION_TIMESTAMP = 0
CREATED_BY = f'{NAME} {VERSION}'
WEBSITE = 'https://openaedmap.org'
Expand Down Expand Up @@ -50,9 +50,12 @@
MVT_EXTENT = 4096
MVT_TRANSFORMER = Transformer.from_crs(OSM_PROJ, MVT_PROJ, always_xy=True)

IMAGE_CONTENT_TYPES = {'image/jpeg', 'image/png', 'image/webp'}
IMAGE_LIMIT_PIXELS = 6 * 1000 * 1000 # 6 MP (e.g., 3000x2000)
IMAGE_MAX_FILE_SIZE = 2 * 1024 * 1024 # 2 MB

REMOTE_IMAGE_MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB

DATA_DIR = Path('data')
PHOTOS_DIR = DATA_DIR / 'photos'

Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ version = "0.0.0"
anyio = "^4.2.0"
asyncache = "^0.3.1"
authlib = "^1.3.0"
brotlicffi = "^1.1.0.0"
cachetools = "^5.3.2"
cython = "^3.0.7"
dacite = "^1.8.1"
Expand All @@ -28,15 +29,14 @@ pyproj = "^3.6.1"
python = "^3.12"
python-magic = "^0.4.27"
python-multipart = "^0.0.6"
pytz = "^2023.3.post1"
scikit-learn = "^1.3.2"
setuptools = "^69.0.3"
shapely = "^2.0.2"
tqdm = "^4.66.1"
tzfpy = "^0.15.3"
uvicorn = {extras = ["standard"], version = "^0.25.0"}
xmltodict = "^0.13.0"
tzfpy = "^0.15.3"
pytz = "^2023.3.post1"
brotlicffi = "^1.1.0.0"

[build-system]
build-backend = "poetry.core.masonry.api"
Expand Down

0 comments on commit e6517d6

Please sign in to comment.