diff --git a/backend/server/adventures/migrations/0059_alter_activity_options.py b/backend/server/adventures/migrations/0059_alter_activity_options.py new file mode 100644 index 0000000..f247d77 --- /dev/null +++ b/backend/server/adventures/migrations/0059_alter_activity_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.2 on 2025-08-04 16:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0058_alter_collectioninvite_options_activity'), + ] + + operations = [ + migrations.AlterModelOptions( + name='activity', + options={'verbose_name': 'Activity', 'verbose_name_plural': 'Activities'}, + ), + ] diff --git a/backend/server/integrations/admin.py b/backend/server/integrations/admin.py index 1596a6e..915b954 100644 --- a/backend/server/integrations/admin.py +++ b/backend/server/integrations/admin.py @@ -1,10 +1,11 @@ from django.contrib import admin from allauth.account.decorators import secure_admin_login -from .models import ImmichIntegration, StravaToken +from .models import ImmichIntegration, StravaToken, WandererIntegration admin.autodiscover() admin.site.login = secure_admin_login(admin.site.login) admin.site.register(ImmichIntegration) -admin.site.register(StravaToken) \ No newline at end of file +admin.site.register(StravaToken) +admin.site.register(WandererIntegration) \ No newline at end of file diff --git a/backend/server/integrations/migrations/0004_wandererintegration.py b/backend/server/integrations/migrations/0004_wandererintegration.py new file mode 100644 index 0000000..7e20f03 --- /dev/null +++ b/backend/server/integrations/migrations/0004_wandererintegration.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.2 on 2025-08-04 16:40 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0003_stravatoken'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WandererIntegration', + fields=[ + ('server_url', models.CharField(max_length=255)), + ('username', models.CharField(max_length=255)), + ('token', models.CharField(blank=True, max_length=255, null=True)), + ('token_expiry', models.DateTimeField(blank=True, null=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wanderer_integrations', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Wanderer Integration', + 'verbose_name_plural': 'Wanderer Integrations', + }, + ), + ] diff --git a/backend/server/integrations/migrations/0005_alter_wandererintegration_token.py b/backend/server/integrations/migrations/0005_alter_wandererintegration_token.py new file mode 100644 index 0000000..d3a032b --- /dev/null +++ b/backend/server/integrations/migrations/0005_alter_wandererintegration_token.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.2 on 2025-08-04 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0004_wandererintegration'), + ] + + operations = [ + migrations.AlterField( + model_name='wandererintegration', + name='token', + field=models.CharField(blank=True, max_length=1000, null=True), + ), + ] diff --git a/backend/server/integrations/migrations/0006_alter_wandererintegration_token.py b/backend/server/integrations/migrations/0006_alter_wandererintegration_token.py new file mode 100644 index 0000000..e758665 --- /dev/null +++ b/backend/server/integrations/migrations/0006_alter_wandererintegration_token.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.2 on 2025-08-04 17:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0005_alter_wandererintegration_token'), + ] + + operations = [ + migrations.AlterField( + model_name='wandererintegration', + name='token', + field=models.CharField(blank=True, null=True), + ), + ] diff --git a/backend/server/integrations/models.py b/backend/server/integrations/models.py index 24cb802..79dc837 100644 --- a/backend/server/integrations/models.py +++ b/backend/server/integrations/models.py @@ -23,4 +23,20 @@ class StravaToken(models.Model): expires_at = models.BigIntegerField() # Unix timestamp athlete_id = models.BigIntegerField(null=True, blank=True) scope = models.CharField(max_length=255, null=True, blank=True) - updated_at = models.DateTimeField(auto_now=True) \ No newline at end of file + updated_at = models.DateTimeField(auto_now=True) + +class WandererIntegration(models.Model): + server_url = models.CharField(max_length=255) + username = models.CharField(max_length=255) + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name='wanderer_integrations') + token = models.CharField(null=True, blank=True) + token_expiry = models.DateTimeField(null=True, blank=True) + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + + def __str__(self): + return self.user.username + ' - ' + self.server_url + + class Meta: + verbose_name = "Wanderer Integration" + verbose_name_plural = "Wanderer Integrations" \ No newline at end of file diff --git a/backend/server/integrations/urls.py b/backend/server/integrations/urls.py index 049832f..b3994db 100644 --- a/backend/server/integrations/urls.py +++ b/backend/server/integrations/urls.py @@ -1,7 +1,7 @@ from integrations.views import * from django.urls import path, include from rest_framework.routers import DefaultRouter -from integrations.views import IntegrationView, StravaIntegrationView +from integrations.views import IntegrationView, StravaIntegrationView, WandererIntegrationViewSet # Create the router and register the ViewSet router = DefaultRouter() @@ -9,6 +9,7 @@ router.register(r'immich', ImmichIntegrationView, basename='immich') router.register(r'', IntegrationView, basename='integrations') router.register(r'immich', ImmichIntegrationViewSet, basename='immich_viewset') router.register(r'strava', StravaIntegrationView, basename='strava') +router.register(r'wanderer', WandererIntegrationViewSet, basename='wanderer') # Include the router URLs urlpatterns = [ diff --git a/backend/server/integrations/views/__init__.py b/backend/server/integrations/views/__init__.py index 78e17d4..9c727de 100644 --- a/backend/server/integrations/views/__init__.py +++ b/backend/server/integrations/views/__init__.py @@ -1,3 +1,4 @@ from .immich_view import ImmichIntegrationView, ImmichIntegrationViewSet from .integration_view import IntegrationView -from .strava_view import StravaIntegrationView \ No newline at end of file +from .strava_view import StravaIntegrationView +from .wanderer_view import WandererIntegrationViewSet \ No newline at end of file diff --git a/backend/server/integrations/views/integration_view.py b/backend/server/integrations/views/integration_view.py index 1ee54ba..2e35934 100644 --- a/backend/server/integrations/views/integration_view.py +++ b/backend/server/integrations/views/integration_view.py @@ -2,7 +2,7 @@ import os from rest_framework.response import Response from rest_framework import viewsets, status from rest_framework.permissions import IsAuthenticated -from integrations.models import ImmichIntegration, StravaToken +from integrations.models import ImmichIntegration, StravaToken, WandererIntegration from django.conf import settings @@ -16,6 +16,7 @@ class IntegrationView(viewsets.ViewSet): google_map_integration = settings.GOOGLE_MAPS_API_KEY != '' strava_integration_global = settings.STRAVA_CLIENT_ID != '' and settings.STRAVA_CLIENT_SECRET != '' strava_integration_user = StravaToken.objects.filter(user=request.user).exists() + wanderer_integration = WandererIntegration.objects.filter(user=request.user).exists() return Response( { @@ -24,7 +25,8 @@ class IntegrationView(viewsets.ViewSet): 'strava': { 'global': strava_integration_global, 'user': strava_integration_user - } + }, + 'wanderer': wanderer_integration }, status=status.HTTP_200_OK ) diff --git a/backend/server/integrations/views/wanderer_view.py b/backend/server/integrations/views/wanderer_view.py new file mode 100644 index 0000000..aec5794 --- /dev/null +++ b/backend/server/integrations/views/wanderer_view.py @@ -0,0 +1,141 @@ +# views.py +import requests +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.exceptions import ValidationError, NotFound + +from integrations.models import WandererIntegration +from integrations.wanderer_services import get_valid_session, login_to_wanderer, IntegrationError +from django.utils import timezone + +class WandererIntegrationViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + def _get_obj(self): + try: + return WandererIntegration.objects.filter(user=self.request.user).first() + except WandererIntegration.DoesNotExist: + raise NotFound("Wanderer integration not found.") + + # def list(self, request): + # try: + # inst = self._get_obj() + # except NotFound: + # return Response([], status=status.HTTP_200_OK) + # return Response({ + # "id": inst.id, + # "server_url": inst.server_url, + # "username": inst.username, + # "is_connected": bool(inst.token and inst.token_expiry and inst.token_expiry > timezone.now()), + # "token_expiry": inst.token_expiry, + # }) + + def create(self, request): + if WandererIntegration.objects.filter(user=request.user).exists(): + raise ValidationError("Wanderer integration already exists. Use UPDATE instead.") + + server_url = request.data.get("server_url") + username = request.data.get("username") + password = request.data.get("password") + if not server_url or not username or not password: + raise ValidationError( + "Must provide server_url, username + password in request data." + ) + + inst = WandererIntegration( + user=request.user, + server_url=server_url.rstrip("/"), + username=username, + ) + + try: + token, expiry = login_to_wanderer(inst, password) + except IntegrationError as e: + raise ValidationError({"error": str(e)}) + + inst.token = token + inst.token_expiry = expiry + inst.save() + + return Response( + {"message": "Wanderer integration created and authenticated successfully."}, + status=status.HTTP_201_CREATED, + ) + + def update(self, request, pk=None): + inst = self._get_obj() + changed = False + for field in ("server_url", "username"): + if field in request.data and getattr(inst, field) != request.data[field]: + setattr(inst, field, request.data[field].rstrip("/") if field=="server_url" else request.data[field]) + changed = True + + password = request.data.get("password") + if not changed and not password: + return Response( + {"detail": "Nothing updated: send at least one of server_url, username, or password."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # If password provided: re-auth / token renewal + if password: + try: + token, expiry = login_to_wanderer(inst, password) + except IntegrationError as e: + raise ValidationError({"error": str(e)}) + inst.token = token + inst.token_expiry = expiry + + inst.save() + return Response({"message": "Integration updated successfully."}) + + @action(detail=False, methods=["post"]) + def disable(self, request): + inst = self._get_obj() + inst.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=False, methods=["post"]) + def refresh(self, request): + inst = self._get_obj() + + password = request.data.get("password") + try: + session = get_valid_session(inst, password_for_reauth=password) + except IntegrationError as e: + raise ValidationError({"detail": str(e)}) + + return Response({ + "token": inst.token, + "token_expiry": inst.token_expiry, + "is_connected": True, + }) + + @action(detail=False, methods=["get"], url_path='list') + def list_trails(self, request): + inst = self._get_obj() + + # Check if we need to prompt for password + password = request.query_params.get("password") # Allow password via query param if needed + + try: + session = get_valid_session(inst, password_for_reauth=password) + except IntegrationError as e: + # If session expired and no password provided, give a helpful error + if "password is required" in str(e).lower(): + raise ValidationError({ + "detail": str(e), + "requires_password": True + }) + raise ValidationError({"detail": str(e)}) + + url = f"{inst.server_url.rstrip('/')}/api/v1/list" + try: + response = session.get(url, timeout=10) + response.raise_for_status() + except requests.RequestException as exc: + raise ValidationError({"detail": f"Error fetching trails: {exc}"}) + + return Response(response.json()) \ No newline at end of file diff --git a/backend/server/integrations/wanderer_services.py b/backend/server/integrations/wanderer_services.py new file mode 100644 index 0000000..5c2e68d --- /dev/null +++ b/backend/server/integrations/wanderer_services.py @@ -0,0 +1,111 @@ +# wanderer_services.py +import requests +from datetime import datetime +from datetime import timezone as dt_timezone +from django.utils import timezone as django_timezone +import logging + +from .models import WandererIntegration + +logger = logging.getLogger(__name__) + +class IntegrationError(Exception): + pass + +# Use both possible cookie names +COOKIE_NAMES = ("pb_auth", "pb-auth") +LOGIN_PATH = "/api/v1/auth/login" + +def login_to_wanderer(integration: WandererIntegration, password: str): + """ + Authenticate with Wanderer and return the auth cookie and expiry. + """ + url = integration.server_url.rstrip("/") + LOGIN_PATH + + try: + resp = requests.post(url, json={ + "username": integration.username, + "password": password + }, timeout=10) + resp.raise_for_status() + except requests.RequestException as exc: + logger.error("Error connecting to Wanderer login: %s", exc) + raise IntegrationError("Could not connect to Wanderer server.") + + # Log only summary (not full token body) + logger.debug("Wanderer login status: %s, headers: %s", resp.status_code, resp.headers.get("Set-Cookie")) + + # Extract auth cookie and expiry + token = None + expiry = None + for cookie in resp.cookies: + if cookie.name in COOKIE_NAMES: + token = cookie.value + if cookie.expires: + expiry = datetime.fromtimestamp(cookie.expires, tz=dt_timezone.utc) + else: + # If no expiry set, assume 24 hours from now + expiry = django_timezone.now() + django_timezone.timedelta(hours=24) + break + + if not token: + logger.error("Wanderer login succeeded but no auth cookie in response.") + raise IntegrationError("Authentication succeeded, but token cookie not found.") + + logger.info(f"Successfully authenticated with Wanderer. Token expires: {expiry}") + return token, expiry + +def get_valid_session(integration: WandererIntegration, password_for_reauth: str = None): + """ + Get a requests session with valid authentication. + Will reuse existing token if valid, or re-authenticate if needed. + """ + now = django_timezone.now() + session = requests.Session() + + # Check if we have a valid token + if integration.token and integration.token_expiry and integration.token_expiry > now: + logger.debug("Using existing valid token") + session.cookies.set(COOKIE_NAMES[0], integration.token) + return session + + # Token expired or missing - need to re-authenticate + if password_for_reauth is None: + raise IntegrationError("Session expired; password is required to reconnect.") + + logger.info("Token expired, re-authenticating with Wanderer") + token, expiry = login_to_wanderer(integration, password_for_reauth) + + # Update the integration with new token + integration.token = token + integration.token_expiry = expiry + integration.save(update_fields=["token", "token_expiry"]) + + # Set the cookie in the session + session.cookies.set(COOKIE_NAMES[0], token) + return session + +def make_wanderer_request(integration: WandererIntegration, endpoint: str, method: str = "GET", password_for_reauth: str = None, **kwargs): + """ + Helper function to make authenticated requests to Wanderer API. + + Args: + integration: WandererIntegration instance + endpoint: API endpoint (e.g., '/api/v1/list') + method: HTTP method (GET, POST, etc.) + password_for_reauth: Password to use if re-authentication is needed + **kwargs: Additional arguments to pass to requests method + + Returns: + requests.Response object + """ + session = get_valid_session(integration, password_for_reauth) + url = f"{integration.server_url.rstrip('/')}{endpoint}" + + try: + response = getattr(session, method.lower())(url, timeout=10, **kwargs) + response.raise_for_status() + return response + except requests.RequestException as exc: + logger.error(f"Error making {method} request to {url}: {exc}") + raise IntegrationError(f"Error communicating with Wanderer: {exc}") \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 55b4a44..185bf26 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -799,5 +799,10 @@ "authorization_error": "Error redirecting to strava authorization URL", "disconnected": "Successfully disconnected from Strava", "disconnect_error": "Error disconnecting from Strava" + }, + "wanderer": { + "wanderer_integration_desc": "Connect to Wanderer to easily import and view your trails in locations", + "connection_error": "Error connecting to Wanderer", + "connected": "Successfully connected to Wanderer" } } diff --git a/frontend/src/routes/settings/+page.server.ts b/frontend/src/routes/settings/+page.server.ts index b1bcdd5..e164ba8 100644 --- a/frontend/src/routes/settings/+page.server.ts +++ b/frontend/src/routes/settings/+page.server.ts @@ -82,6 +82,7 @@ export const load: PageServerLoad = async (event) => { let googleMapsEnabled = integrations.google_maps as boolean; let stravaGlobalEnabled = integrations.strava.global as boolean; let stravaUserEnabled = integrations.strava.user as boolean; + let wandererEnabled = integrations.wanderer as boolean; let publicUrlFetch = await fetch(`${endpoint}/public-url/`); let publicUrl = ''; @@ -102,7 +103,8 @@ export const load: PageServerLoad = async (event) => { socialProviders, googleMapsEnabled, stravaGlobalEnabled, - stravaUserEnabled + stravaUserEnabled, + wandererEnabled } }; }; diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 4dfdd3e..fa026f8 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -28,6 +28,7 @@ let googleMapsEnabled = data.props.googleMapsEnabled; let stravaGlobalEnabled = data.props.stravaGlobalEnabled; let stravaUserEnabled = data.props.stravaUserEnabled; + let wandererEnabled = data.props.wandererEnabled; let activeSection: string = 'profile'; // Initialize activeSection from URL on mount @@ -55,6 +56,12 @@ copy_locally: true }; + let newWandererIntegration = { + server_url: '', + username: '', + password: '' + }; + let isMFAModalOpen: boolean = false; const sections = [ @@ -321,6 +328,39 @@ addToast('error', $t('strava.disconnect_error')); } } + + async function wandererDisconnect() { + const res = await fetch('/api/integrations/wanderer/disable/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + if (res.ok) { + addToast('success', $t('wanderer.disconnected')); + wandererEnabled = false; + } else { + addToast('error', $t('wanderer.disconnect_error')); + } + } + + async function wandererConnect() { + const res = await fetch('/api/integrations/wanderer/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(newWandererIntegration) + }); + if (res.ok) { + addToast('success', $t('wanderer.connected')); + wandererEnabled = true; + newWandererIntegration = { server_url: '', username: '', password: '' }; + } else { + const data = await res.json(); + addToast('error', $t('wanderer.connection_error')); + } + } {#if isMFAModalOpen} @@ -1054,10 +1094,10 @@

Wanderer

- {$t('strava.strava_integration_desc')} + {$t('wanderer.wanderer_integration_desc')}

- {#if stravaGlobalEnabled && stravaUserEnabled} + {#if wandererEnabled}
{$t('settings.connected')}
{:else}
{$t('settings.disconnected')}
@@ -1065,25 +1105,56 @@ - {#if !stravaGlobalEnabled} - -
-

- {$t('strava.not_enabled') || - 'Strava integration is not enabled on this instance.'} -

-
- {:else if !stravaUserEnabled && stravaGlobalEnabled} - -
-
- {:else if stravaGlobalEnabled && stravaUserEnabled} + {:else}
-
@@ -1097,7 +1168,7 @@ 📖 {$t('immich.need_help')} {$t('navbar.documentation')}