diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..835e7cf --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +ifeq ($(OS),Windows_NT) +PYTHON := py -3 +BACKEND_PYTHON := venv/Scripts/python.exe +NPM := npm.cmd +else +PYTHON := python3 +BACKEND_PYTHON := venv/bin/python3 +NPM := npm +endif + +.PHONY: dev backend frontend + +dev: + $(PYTHON) scripts/dev_runner.py + +backend: + cd backend && $(BACKEND_PYTHON) app.py + +frontend: + cd frontend && $(NPM) start diff --git a/backend/README.md b/backend/README.md index 389fa52..7b98c48 100644 --- a/backend/README.md +++ b/backend/README.md @@ -82,6 +82,28 @@ The server will start on `http://localhost:8000` - **PUT** `/api/study_spots/` - Update a study spot (send only fields to update) - **DELETE** `/api/study_spots/` - Delete a study spot +`GET /api/study_spots*` responses return `pictures` as backend image API URLs in the format: +`/api/study_spots//images/` + +### Images +- **GET** `/api/study_spots//images/` - Proxy an image by study spot ID and picture index (recommended for frontend use) +- **GET** `/api/image_proxy?url=` - Proxy a remote image URL directly (useful for one-off testing/debugging) + +### Uploading and Accessing Images +This backend stores image **URLs** in the database (it does not accept raw image file uploads). + +1. Host each image at a publicly accessible URL (Google Drive public links are supported and normalized). +2. Create or update a study spot with those URLs in the `pictures` array via: + - `POST /api/study_spots` + - `PUT /api/study_spots/` +3. Read the study spot from: + - `GET /api/study_spots` + - `GET /api/study_spots/` +4. Use each returned `pictures` value directly in the frontend. These values are backend API endpoints like: + - `http://localhost:8000/api/study_spots/1/images/0` +5. Optional: For a specific source URL, you can also access it through: + - `GET /api/image_proxy?url=` + ## Database The application uses SQLite by default for development. The database file will be created automatically as `longhorn_studies.db` when you first run the application. @@ -125,3 +147,11 @@ The application runs in debug mode by default for development. For production: pip install gunicorn gunicorn app:app ``` + +To normalize existing image links in the database: + +```bash +cd backend +source venv/bin/activate +python scripts/normalize_picture_links.py +``` diff --git a/backend/app.py b/backend/app.py index 666767a..5d7f6ec 100644 --- a/backend/app.py +++ b/backend/app.py @@ -43,4 +43,4 @@ # WARNING: Debug mode should be disabled in production # Set FLASK_ENV=production in .env for production deployment debug_mode = os.getenv('FLASK_ENV', 'development') != 'production' - app.run(debug=debug_mode, host='0.0.0.0', port=8000) + app.run(debug=debug_mode, host='0.0.0.0', port=8000, threaded=True) diff --git a/backend/instance/longhorn_studies.db b/backend/instance/longhorn_studies.db index 47b146a..71a812b 100644 Binary files a/backend/instance/longhorn_studies.db and b/backend/instance/longhorn_studies.db differ diff --git a/backend/models.py b/backend/models.py index 57e216d..9418ad7 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,5 @@ from database import db +from url_utils import normalize_picture_urls def default_access_hours(): @@ -39,7 +40,7 @@ def to_dict(self): 'address': self.address, 'floor': self.floor, 'tags': self.tags if self.tags is not None else [], - 'pictures': self.pictures if self.pictures is not None else [], + 'pictures': normalize_picture_urls(self.pictures), 'noise_level': self.noise_level, 'capacity': self.capacity, 'spot_type': self.spot_type if self.spot_type is not None else [], diff --git a/backend/routes.py b/backend/routes.py index 2299840..804cb75 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -1,9 +1,15 @@ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, Response from database import db from models import StudySpot from datetime import datetime +from collections import OrderedDict import logging import re +import threading +import time +from urllib.parse import parse_qs, urlparse +from urllib import request as urllib_request, error as urllib_error +from url_utils import normalize_picture_urls # Configure logging logger = logging.getLogger(__name__) @@ -58,6 +64,13 @@ def health_check(): } TIME_PATTERN = re.compile(r'^([01]\d|2[0-3]):([0-5]\d)$') +DRIVE_FILE_PATH_RE = re.compile(r'/file/d/([A-Za-z0-9_-]+)') +SPOT_IMAGE_PATH_RE = re.compile(r'/api/study_spots/(\d+)/images/(\d+)/?$') +LOCAL_HOSTNAMES = {'localhost', '127.0.0.1', '::1'} +IMAGE_CACHE_MAX_ENTRIES = 256 +IMAGE_CACHE_TTL_SECONDS = 3600 +IMAGE_CACHE = OrderedDict() +IMAGE_CACHE_LOCK = threading.Lock() def _normalize_access_hours(value): @@ -81,6 +94,204 @@ def _normalize_access_hours(value): return normalized +def _extract_google_drive_file_id(url): + """Extract Google Drive file id from common URL formats.""" + parsed = urlparse(url) + hostname = (parsed.hostname or '').lower() + + if 'drive.google.com' not in hostname and 'docs.google.com' not in hostname: + return None + + path_match = DRIVE_FILE_PATH_RE.search(parsed.path or '') + if path_match: + return path_match.group(1) + + query_params = parse_qs(parsed.query or '') + file_id = query_params.get('id', [None])[0] + if file_id: + return file_id + + return None + + +def _is_safe_remote_url(url): + """Basic SSRF protection for proxy endpoint.""" + parsed = urlparse(url) + if parsed.scheme not in {'http', 'https'}: + return False + hostname = (parsed.hostname or '').lower() + if not hostname: + return False + if hostname in LOCAL_HOSTNAMES: + return False + return True + + +def _candidate_proxy_urls(url): + """ + Build candidate image URLs. + For Google Drive, try several direct variants since availability differs per file. + """ + file_id = _extract_google_drive_file_id(url) + if not file_id: + return [url] + + # Prefer direct image variants first to avoid slow HTML responses. + candidates = [ + f'https://drive.google.com/thumbnail?id={file_id}&sz=w1200', + f'https://lh3.googleusercontent.com/d/{file_id}=w1200', + f'https://drive.google.com/uc?export=download&id={file_id}', + f'https://docs.google.com/uc?export=download&id={file_id}', + f'https://drive.google.com/uc?export=view&id={file_id}', + url, + ] + return list(dict.fromkeys(candidates)) + + +def _fetch_remote_image(url): + req = urllib_request.Request( + url, + headers={ + 'User-Agent': 'LonghornStudiesImageProxy/1.0', + 'Accept': 'image/*,*/*;q=0.8', + } + ) + + with urllib_request.urlopen(req, timeout=15) as upstream: + content_type = (upstream.headers.get('Content-Type') or '').split(';', 1)[0].strip().lower() + body = upstream.read() + return body, content_type + + +def _build_image_response(body, content_type): + response = Response(body, status=200, content_type=content_type) + response.headers['Cache-Control'] = f'public, max-age={IMAGE_CACHE_TTL_SECONDS}' + return response + + +def _get_cached_image(url): + now = time.time() + with IMAGE_CACHE_LOCK: + cached = IMAGE_CACHE.get(url) + if cached is None: + return None + + expires_at = cached['expires_at'] + if expires_at <= now: + del IMAGE_CACHE[url] + return None + + IMAGE_CACHE.move_to_end(url) + return cached['body'], cached['content_type'] + + +def _set_cached_image(url, body, content_type): + with IMAGE_CACHE_LOCK: + IMAGE_CACHE[url] = { + 'body': body, + 'content_type': content_type, + 'expires_at': time.time() + IMAGE_CACHE_TTL_SECONDS, + } + IMAGE_CACHE.move_to_end(url) + + while len(IMAGE_CACHE) > IMAGE_CACHE_MAX_ENTRIES: + IMAGE_CACHE.popitem(last=False) + + +def _proxy_image_from_remote_url(raw_url): + """Fetch and proxy an image from a remote URL.""" + errors = [] + if not _is_safe_remote_url(raw_url): + return None, ['Unsupported or unsafe URL'] + + for candidate in _candidate_proxy_urls(raw_url): + if not _is_safe_remote_url(candidate): + errors.append(f'unsupported candidate URL: {candidate}') + continue + + cached = _get_cached_image(candidate) + if cached is not None: + body, content_type = cached + return _build_image_response(body, content_type), errors + + try: + body, content_type = _fetch_remote_image(candidate) + if not content_type.startswith('image/'): + errors.append(f'non-image content-type from {candidate}: {content_type or "unknown"}') + continue + + _set_cached_image(candidate, body, content_type) + return _build_image_response(body, content_type), errors + except urllib_error.HTTPError as err: + errors.append(f'HTTP {err.code} from {candidate}') + except urllib_error.URLError as err: + errors.append(f'URL error from {candidate}: {err.reason}') + except Exception as err: + errors.append(f'Unexpected error from {candidate}: {err}') + + return None, errors + + +def _spot_image_endpoint_url(spot_id, image_index): + """Build a stable image endpoint URL for a specific spot image index.""" + base = request.url_root.rstrip('/') + return f'{base}/api/study_spots/{spot_id}/images/{image_index}' + + +def _get_spot_picture_url_by_index(spot, image_index): + """Return the normalized remote picture URL at index, or None if invalid.""" + pictures = normalize_picture_urls(spot.pictures) + if image_index < 0 or image_index >= len(pictures): + return None + picture_url = pictures[image_index] + if not isinstance(picture_url, str): + return None + picture_url = picture_url.strip() + if not picture_url: + return None + return picture_url + + +def _resolve_spot_image_endpoint_url(url, current_spot=None): + """Resolve /api/study_spots//images/ URLs to original remote URLs.""" + parsed = urlparse(url) + path = parsed.path or '' + match = SPOT_IMAGE_PATH_RE.match(path) + if not match: + return url + + source_spot_id = int(match.group(1)) + source_image_index = int(match.group(2)) + + if current_spot is not None and current_spot.id == source_spot_id: + source_spot = current_spot + else: + source_spot = StudySpot.query.get(source_spot_id) + + if source_spot is None: + return url + + resolved = _get_spot_picture_url_by_index(source_spot, source_image_index) + return resolved if resolved is not None else url + + +def _normalize_incoming_pictures(pictures, current_spot=None): + """Normalize incoming pictures and unwrap internal image endpoint URLs.""" + if pictures is None: + return [] + if not isinstance(pictures, list): + return [] + + resolved = [] + for picture in pictures: + if isinstance(picture, str): + resolved.append(_resolve_spot_image_endpoint_url(picture.strip(), current_spot=current_spot)) + else: + resolved.append(picture) + + return normalize_picture_urls(resolved) + + def _study_spot_from_json(data, spot=None): """Build or update StudySpot from request JSON. Returns (StudySpot, error_response).""" if spot is None: @@ -119,7 +330,7 @@ def _study_spot_from_json(data, spot=None): if 'description' in data: spot.description = data['description'] if 'pictures' in data: - spot.pictures = list(data['pictures']) if data['pictures'] is not None else [] + spot.pictures = _normalize_incoming_pictures(data['pictures'], current_spot=spot) # Optional if 'building_name' in data: spot.building_name = data['building_name'] if data['building_name'] else None @@ -139,6 +350,62 @@ def _study_spot_from_json(data, spot=None): return spot, None +def _serialize_spot(spot): + """Serialize a StudySpot with ID-based proxied image URLs.""" + payload = spot.to_dict() + pictures = payload.get('pictures', []) + payload['pictures'] = [ + _spot_image_endpoint_url(spot.id, index) + for index in range(len(pictures)) + ] + return payload + + +@api_bp.route('/image_proxy', methods=['GET']) +def image_proxy(): + """ + Proxy remote images so web clients can render DB image URLs reliably. + Query param: ?url= + """ + raw_url = (request.args.get('url') or '').strip() + if not raw_url: + return jsonify({'error': 'Missing url query parameter'}), 400 + proxied_response, errors = _proxy_image_from_remote_url(raw_url) + if proxied_response is not None: + return proxied_response + + logger.warning('Image proxy failed for %s. Attempts: %s', raw_url, ' | '.join(errors)) + return jsonify({'error': 'Failed to fetch image from upstream URL'}), 502 + + +@api_bp.route('/study_spots//images/', methods=['GET']) +def get_study_spot_image(spot_id, image_index): + """Fetch a study spot image by study spot ID and image index.""" + try: + spot = StudySpot.query.get(spot_id) + if spot is None: + return jsonify({'error': 'Study spot not found'}), 404 + + picture_url = _get_spot_picture_url_by_index(spot, image_index) + if picture_url is None: + return jsonify({'error': 'Image not found'}), 404 + + proxied_response, errors = _proxy_image_from_remote_url(picture_url) + if proxied_response is not None: + return proxied_response + + logger.warning( + 'Study spot image proxy failed for spot_id=%s image_index=%s. Attempts: %s', + spot_id, + image_index, + ' | '.join(errors), + ) + return jsonify({'error': 'Failed to fetch study spot image'}), 502 + except Exception as e: + logger.error(f"Error fetching study spot image spot_id={spot_id} image_index={image_index}: {str(e)}") + return jsonify({'error': 'Failed to fetch study spot image'}), 500 + + # Study spot endpoints @api_bp.route('/study_spots', methods=['GET']) def get_study_spots(): @@ -147,7 +414,7 @@ def get_study_spots(): """ try: spots = StudySpot.query.all() - return jsonify([s.to_dict() for s in spots]), 200 + return jsonify([_serialize_spot(s) for s in spots]), 200 except Exception as e: logger.error(f"Error fetching study spots: {str(e)}") return jsonify({'error': 'Failed to fetch study spots'}), 500 @@ -160,7 +427,7 @@ def get_study_spot(spot_id): """ try: spot = StudySpot.query.get_or_404(spot_id) - return jsonify(spot.to_dict()), 200 + return jsonify(_serialize_spot(spot)), 200 except Exception as e: logger.error(f"Error fetching study spot {spot_id}: {str(e)}") return jsonify({'error': 'Study spot not found'}), 404 @@ -190,7 +457,7 @@ def create_study_spot(): db.session.add(spot) db.session.commit() logger.info(f"Created study spot: {spot.study_spot_name} (ID: {spot.id})") - return jsonify(spot.to_dict()), 201 + return jsonify(_serialize_spot(spot)), 201 except ValueError as e: db.session.rollback() return jsonify({'error': str(e)}), 400 @@ -215,7 +482,7 @@ def update_study_spot(spot_id): return err db.session.commit() logger.info(f"Updated study spot: {spot.study_spot_name} (ID: {spot.id})") - return jsonify(spot.to_dict()), 200 + return jsonify(_serialize_spot(spot)), 200 except ValueError as e: db.session.rollback() return jsonify({'error': str(e)}), 400 diff --git a/backend/url_utils.py b/backend/url_utils.py new file mode 100644 index 0000000..8313b8e --- /dev/null +++ b/backend/url_utils.py @@ -0,0 +1,87 @@ +from urllib.parse import parse_qs, urlparse +import re + + +DRIVE_FILE_PATH_RE = re.compile(r"/file/d/([A-Za-z0-9_-]+)") +IMAGE_PROXY_PATH_SUFFIX = "/api/image_proxy" + + +def _unwrap_image_proxy_once(url): + """If URL is our image proxy endpoint, return the nested source URL.""" + parsed = urlparse(url) + path = (parsed.path or "").rstrip("/") + if not path.endswith(IMAGE_PROXY_PATH_SUFFIX): + return url + + nested = parse_qs(parsed.query or "").get("url", [None])[0] + if isinstance(nested, str): + nested = nested.strip() + if nested: + return nested + + return url + + +def _unwrap_image_proxy_url(url): + """Unwrap up to 3 nested proxy URLs.""" + current = url + for _ in range(3): + unwrapped = _unwrap_image_proxy_once(current) + if unwrapped == current: + break + current = unwrapped + return current + + +def _extract_google_drive_file_id(url): + """Extract a Google Drive file id from common public share URL formats.""" + parsed = urlparse(url) + hostname = (parsed.hostname or "").lower() + + if "drive.google.com" not in hostname and "docs.google.com" not in hostname: + return None + + path_match = DRIVE_FILE_PATH_RE.search(parsed.path or "") + if path_match: + return path_match.group(1) + + query_params = parse_qs(parsed.query or "") + file_id = query_params.get("id", [None])[0] + if file_id: + return file_id + + return None + + +def normalize_google_drive_url(url): + """ + Convert Google Drive share URLs into a direct image URL. + Returns the input URL unchanged if no Drive file id is found. + """ + if not isinstance(url, str): + return url + + url = url.strip() + if not url: + return url + + # If a proxied URL is passed back from clients, keep DB storage canonical. + url = _unwrap_image_proxy_url(url) + + file_id = _extract_google_drive_file_id(url) + if not file_id: + return url + + return f"https://drive.google.com/uc?export=view&id={file_id}" + + +def normalize_picture_urls(pictures): + """ + Normalize a list of image URLs for mobile-friendly rendering. + """ + if pictures is None: + return [] + if not isinstance(pictures, list): + return [] + + return [normalize_google_drive_url(url) for url in pictures] diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index f518c9b..e8c4cd5 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -17,6 +17,7 @@ export default function RootLayout() { + diff --git a/frontend/app/temp-images.tsx b/frontend/app/temp-images.tsx new file mode 100644 index 0000000..15896da --- /dev/null +++ b/frontend/app/temp-images.tsx @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react'; +import { Image, SafeAreaView, ScrollView, StyleSheet } from 'react-native'; + +type StudySpot = { + id: number; + pictures?: string[]; +}; + +const API_BASE = 'http://localhost:8000/api'; + +export default function TempImagesScreen() { + const [images, setImages] = useState([]); + + useEffect(() => { + let active = true; + + const loadImages = async () => { + try { + const response = await fetch(`${API_BASE}/study_spots`); + if (!response.ok) { + return; + } + + const data = (await response.json()) as StudySpot[]; + if (!active || !Array.isArray(data)) { + return; + } + + const urls = data + .flatMap((spot) => { + if (typeof spot.id !== 'number') { + return []; + } + const pictureCount = Array.isArray(spot.pictures) ? spot.pictures.length : 0; + return Array.from( + { length: pictureCount }, + (_, index) => `${API_BASE}/study_spots/${spot.id}/images/${index}` + ); + }); + + setImages(urls); + } catch { + // Intentionally silent: page should only display images. + } + }; + + loadImages(); + + return () => { + active = false; + }; + }, []); + + return ( + + + {images.map((url, index) => ( + + ))} + + + ); +} + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: '#fff', + }, + container: { + padding: 8, + gap: 8, + }, + image: { + width: '100%', + height: 240, + }, +}); diff --git a/scripts/dev_runner.py b/scripts/dev_runner.py new file mode 100644 index 0000000..822fe65 --- /dev/null +++ b/scripts/dev_runner.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Cross-platform dev runner for backend + frontend.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BACKEND_DIR = ROOT / "backend" +FRONTEND_DIR = ROOT / "frontend" + + +def _resolve_backend_python() -> str: + if os.name == "nt": + candidate = BACKEND_DIR / "venv" / "Scripts" / "python.exe" + else: + candidate = BACKEND_DIR / "venv" / "bin" / "python3" + + if candidate.exists(): + return str(candidate) + + # Fallback if venv path is missing. + return "python3" if os.name != "nt" else "py" + + +def _resolve_frontend_npm() -> str: + return "npm.cmd" if os.name == "nt" else "npm" + + +def _terminate(proc: subprocess.Popen[bytes], timeout_seconds: float = 8.0) -> None: + if proc.poll() is not None: + return + + proc.terminate() + deadline = time.time() + timeout_seconds + while proc.poll() is None and time.time() < deadline: + time.sleep(0.1) + + if proc.poll() is None: + proc.kill() + + +def main() -> int: + backend_python = _resolve_backend_python() + npm_cmd = _resolve_frontend_npm() + + backend_proc = subprocess.Popen([backend_python, "app.py"], cwd=BACKEND_DIR) + frontend_proc = subprocess.Popen([npm_cmd, "start"], cwd=FRONTEND_DIR) + + try: + while True: + backend_code = backend_proc.poll() + frontend_code = frontend_proc.poll() + + if backend_code is not None or frontend_code is not None: + break + + time.sleep(0.25) + except KeyboardInterrupt: + pass + finally: + _terminate(frontend_proc) + _terminate(backend_proc) + + if frontend_proc.returncode not in (None, 0): + return int(frontend_proc.returncode) + if backend_proc.returncode not in (None, 0): + return int(backend_proc.returncode) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())