Bug Fixes + Duplicate Support (#1016)

* Update README.md supporter list

* Fix: Multiple bug fixes and features bundle (#888, #991, #617, #984) (#1007)

* fix: resolve location creation failures, broken image uploads, and invalid URL handling

- Add missing addToast import in LocationDetails.svelte for proper error feedback
- Add objectId check and error response handling in ImageManagement.svelte to prevent ghost images
- Add Content-Type check in +page.server.ts image action to handle non-JSON backend responses
- Add client-side URL validation in LocationDetails.svelte (invalid URLs → null)
- Improve Django field error extraction for user-friendly toast messages
- Clean up empty description fields (whitespace → null)
- Update BUGFIX_DOCUMENTATION.md with detailed fix descriptions

* feat: bug fixes and new features bundle

Bug fixes:
- fix: resolve PATCH location with visits (#888)
- fix: Wikipedia/URL image upload via server-side proxy (#991)
- fix: private/public toggle race condition (#617)
- fix: location creation feedback (addToast import)
- fix: invalid URL handling for locations and collections
- fix: world map country highlighting (bg-*-200 -> bg-*-400)
- fix: clipboard API polyfill for HTTP contexts
- fix: MultipleObjectsReturned for duplicate images
- fix: SvelteKit proxy sessionid cookie forwarding

Features:
- feat: duplicate location button (list + detail view)
- feat: duplicate collection button
- feat: i18n translations for 19 languages
- feat: improved error handling and user feedback

Technical:
- Backend: fetch_from_url endpoint with SSRF protection
- Backend: validate_link() for collections
- Backend: file_permissions filter() instead of get()
- Frontend: copyToClipboard() helper function
- Frontend: clipboard polyfill via server-side injection

* chore: switch docker-compose from image to build

Use local source code builds instead of upstream :latest images
to preserve our custom patches and fixes.

* fix: lodging save errors, AI language support, and i18n improvements

- Fix Lodging save: add res.ok checks, error toasts, isSaving state (#984)
- Fix URL validation: silently set invalid URLs to null (Lodging, Transportation)
- Fix AI description language: pass user locale to Wikipedia API
- Fix missing i18n keys: Strava toggle buttons (show/hide)
- Add CHANGELOG.md
- Remove internal documentation from public tracking
- Update .gitignore for Cursor IDE and internal docs

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: update location duplication handling, improve UI feedback, and enhance localization support

---------

Co-authored-by: AdventureLog Bugfix <bugfix@adventurelog.local>
Co-authored-by: madmp87 <info@so-pa.de>
Co-authored-by: Mathias Ponnwitz <devuser@dockge-dev.fritz.box>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Sean Morley <mail@seanmorley.com>

* Enhance duplication functionality for collections and locations; update UI to reflect changes

* Potential fix for code scanning alert no. 49: Information exposure through an exception

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Update Django and Pillow versions in requirements.txt

* Fix error logging for image fetch timeout in ContentImageViewSet

* Update requirements.txt to include jaraco.context and wheel for security fixes

* Update app version and add security vulnerabilities to .trivyignore

* Update backend/server/adventures/views/collection_view.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update frontend/src/lib/types.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Reorder build and image directives in docker-compose.yml for clarity

* Refactor code structure for improved readability and maintainability

* Remove inline clipboard polyfill script injection from server hooks (#1019)

* Initial plan

* Remove inline clipboard polyfill script injection from hooks.server.ts

Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com>

* Fix unhandled promise rejections in copyToClipboard click handlers (#1018)

* Initial plan

* Fix: make copyToClipboard handlers async with try/catch error toast

Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com>

* Harden `fetch_from_url` image proxy: require auth, rate-limit, and strengthen SSRF protections (#1017)

* Initial plan

* Harden fetch_from_url: require auth, rate-limit, block non-standard ports, check all IPs, re-validate redirects

Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com>

* Fix subregion filtering in world travel page to exclude null values

* Update package.json to use caret (^) for versioning in overrides

* fix: update package dependencies for compatibility and stability

- Added cookie dependency with version constraint <0.7.0
- Updated svelte dependency to allow versions <=5.51.4
- Updated @sveltejs/adapter-vercel dependency to allow versions <6.3.2

* Refactor code structure for improved readability and maintainability

---------

Co-authored-by: madmp87 <79420509+madmp87@users.noreply.github.com>
Co-authored-by: AdventureLog Bugfix <bugfix@adventurelog.local>
Co-authored-by: madmp87 <info@so-pa.de>
Co-authored-by: Mathias Ponnwitz <devuser@dockge-dev.fritz.box>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Sean Morley
2026-02-20 20:49:24 -05:00
committed by GitHub
parent c008f0c264
commit bec90fe2a5
57 changed files with 21743 additions and 20304 deletions

View File

@@ -1,6 +1,12 @@
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.throttling import UserRateThrottle
from django.http import HttpResponse
import ipaddress
import socket
from urllib.parse import urlparse
from django.db.models import Q
from django.core.files.base import ContentFile
from django.contrib.contenttypes.models import ContentType
@@ -10,6 +16,55 @@ from integrations.models import ImmichIntegration
from adventures.permissions import IsOwnerOrSharedWithFullAccess # Your existing permission class
import requests
from adventures.permissions import ContentImagePermission
import logging
logger = logging.getLogger(__name__)
class ImageProxyThrottle(UserRateThrottle):
scope = 'image_proxy'
def _is_safe_url(image_url):
"""
Validate a URL for safe proxy use.
Returns (True, parsed) on success or (False, error_message) on failure.
Checks:
- Scheme is http or https
- No non-standard ports (only 80 and 443 allowed)
- All resolved IPs are public (no private/loopback/reserved/link-local/multicast)
"""
parsed = urlparse(image_url)
if parsed.scheme not in ('http', 'https'):
return False, "Invalid URL scheme. Only http and https are allowed."
port = parsed.port
if port is not None and port not in (80, 443):
return False, "Non-standard ports are not allowed."
hostname = parsed.hostname
if not hostname:
return False, "Invalid URL: missing hostname."
try:
addr_infos = socket.getaddrinfo(hostname, None)
except socket.gaierror:
return False, "Could not resolve hostname."
if not addr_infos:
return False, "Could not resolve hostname."
for addr_info in addr_infos:
try:
ip = ipaddress.ip_address(addr_info[4][0])
except ValueError:
return False, "Invalid IP address resolved from hostname."
if (ip.is_private or ip.is_loopback or ip.is_reserved
or ip.is_link_local or ip.is_multicast):
return False, "Access to internal networks is not allowed."
return True, parsed
class ContentImageViewSet(viewsets.ModelViewSet):
@@ -119,6 +174,101 @@ class ContentImageViewSet(viewsets.ModelViewSet):
instance.save()
return Response({"success": "Image set as primary image"})
@action(detail=False, methods=['post'],
permission_classes=[IsAuthenticated],
throttle_classes=[ImageProxyThrottle])
def fetch_from_url(self, request):
"""
Authenticated proxy endpoint to fetch images from external URLs.
Avoids CORS issues when the frontend downloads images from third-party
servers (e.g. wikimedia.org). Requires a logged-in user and is
rate-limited to 60 requests/minute.
"""
image_url = request.data.get('url')
if not image_url:
return Response(
{"error": "URL is required"},
status=status.HTTP_400_BAD_REQUEST
)
# Validate the initial URL (scheme, port, SSRF check on all resolved IPs)
safe, result = _is_safe_url(image_url)
if not safe:
return Response({"error": result}, status=status.HTTP_400_BAD_REQUEST)
try:
headers = {'User-Agent': 'AdventureLog/1.0 (Image Proxy)'}
max_redirects = 3
current_url = image_url
for _ in range(max_redirects + 1):
response = requests.get(
current_url,
timeout=10,
headers=headers,
stream=True,
allow_redirects=False,
)
if not response.is_redirect:
break
# Re-validate every redirect destination before following
redirect_url = response.headers.get('Location', '')
if not redirect_url:
return Response(
{"error": "Redirect with missing Location header"},
status=status.HTTP_502_BAD_GATEWAY,
)
safe, result = _is_safe_url(redirect_url)
if not safe:
return Response(
{"error": f"Redirect blocked: {result}"},
status=status.HTTP_400_BAD_REQUEST,
)
current_url = redirect_url
else:
return Response(
{"error": "Too many redirects"},
status=status.HTTP_400_BAD_REQUEST,
)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')
if not content_type.startswith('image/'):
return Response(
{"error": "URL does not point to an image"},
status=status.HTTP_400_BAD_REQUEST
)
content_length = response.headers.get('Content-Length')
if content_length and int(content_length) > 20 * 1024 * 1024:
return Response(
{"error": "Image too large (max 20MB)"},
status=status.HTTP_400_BAD_REQUEST
)
image_data = response.content
return HttpResponse(image_data, content_type=content_type, status=200)
except requests.exceptions.Timeout:
logger.error("Timeout fetching image from URL %s", image_url)
return Response(
{"error": "Download timeout - image may be too large or server too slow"},
status=status.HTTP_504_GATEWAY_TIMEOUT
)
except requests.exceptions.RequestException as e:
logger.error("Failed to fetch image from URL %s: %s", image_url, str(e))
return Response(
{"error": "Failed to fetch image from the remote server"},
status=status.HTTP_502_BAD_GATEWAY
)
def create(self, request, *args, **kwargs):
# Get content type and object ID from request
content_type_name = request.data.get('content_type')
@@ -163,6 +313,20 @@ class ContentImageViewSet(viewsets.ModelViewSet):
"error": f"Invalid content_type. Must be one of: {', '.join(content_type_map.keys())}"
}, status=status.HTTP_400_BAD_REQUEST)
# Validate object_id format (must be a valid UUID, not "undefined" or empty)
if not object_id or object_id == 'undefined':
return Response({
"error": "object_id is required and must be a valid UUID"
}, status=status.HTTP_400_BAD_REQUEST)
import uuid as uuid_module
try:
uuid_module.UUID(str(object_id))
except (ValueError, AttributeError):
return Response({
"error": f"Invalid object_id format: {object_id}"
}, status=status.HTTP_400_BAD_REQUEST)
# Get the content object
try:
content_object = content_type_map[content_type_name].objects.get(id=object_id)