From 06787bccf64780eba057aa4af949358e5c1a04f7 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sun, 1 Jun 2025 19:55:12 -0400 Subject: [PATCH] feat: enhance Immich integration with local copy option and validation for image handling --- backend/nginx.conf | 32 ++++++--------- ..._id_alter_adventureimage_image_and_more.py | 31 ++++++++++++++ ...move_adventureimage_image_xor_immich_id.py | 17 ++++++++ backend/server/adventures/models.py | 34 +++++++++++++-- backend/server/adventures/serializers.py | 10 ++--- .../0002_immichintegration_copy_locally.py | 18 ++++++++ backend/server/integrations/models.py | 1 + .../src/lib/components/AdventureModal.svelte | 41 ++++++++++++++++--- .../src/lib/components/ImmichSelect.svelte | 37 ++++++++++++++++- frontend/src/lib/types.ts | 2 + frontend/src/locales/en.json | 5 ++- frontend/src/routes/settings/+page.svelte | 23 ++++++++++- 12 files changed, 214 insertions(+), 37 deletions(-) create mode 100644 backend/server/adventures/migrations/0031_adventureimage_immich_id_alter_adventureimage_image_and_more.py create mode 100644 backend/server/adventures/migrations/0032_remove_adventureimage_image_xor_immich_id.py create mode 100644 backend/server/integrations/migrations/0002_immichintegration_copy_locally.py diff --git a/backend/nginx.conf b/backend/nginx.conf index 819247e..23dba44 100644 --- a/backend/nginx.conf +++ b/backend/nginx.conf @@ -8,41 +8,35 @@ http { sendfile on; keepalive_timeout 65; client_max_body_size 100M; - + # The backend is running in the same container, so reference localhost upstream django { - server 127.0.0.1:8000; + server 127.0.0.1:8000; # Use localhost to point to Gunicorn running internally } - server { listen 80; server_name localhost; - location / { - proxy_pass http://django; + proxy_pass http://django; # Forward to the upstream block proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - location /static/ { - alias /code/staticfiles/; + alias /code/staticfiles/; # Serve static files directly } - - # Special handling for PDF files with CSP headers - location ~ ^/protectedMedia/(.*)\.pdf$ { - internal; - alias /code/media/$1.pdf; + # Serve protected media files with X-Accel-Redirect + location /protectedMedia/ { + internal; # Only internal requests are allowed + alias /code/media/; # This should match Django MEDIA_ROOT + try_files $uri =404; # Return a 404 if the file doesn't exist + + # Security headers for all protected files add_header Content-Security-Policy "default-src 'self'; script-src 'none'; object-src 'none'; base-uri 'none'" always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options SAMEORIGIN always; - add_header Content-Disposition "inline" always; - } - - # General protected media files (non-PDF) - location ~ ^/protectedMedia/(.*)$ { - internal; - alias /code/media/$1; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; } } } \ No newline at end of file diff --git a/backend/server/adventures/migrations/0031_adventureimage_immich_id_alter_adventureimage_image_and_more.py b/backend/server/adventures/migrations/0031_adventureimage_immich_id_alter_adventureimage_image_and_more.py new file mode 100644 index 0000000..75ebb60 --- /dev/null +++ b/backend/server/adventures/migrations/0031_adventureimage_immich_id_alter_adventureimage_image_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.1 on 2025-06-01 16:57 + +import adventures.models +import django_resized.forms +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0030_set_end_date_equal_start'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='adventureimage', + name='immich_id', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AlterField( + model_name='adventureimage', + name='image', + field=django_resized.forms.ResizedImageField(blank=True, crop=None, force_format='WEBP', keep_meta=True, null=True, quality=75, scale=None, size=[1920, 1080], upload_to=adventures.models.PathAndRename('images/')), + ), + migrations.AddConstraint( + model_name='adventureimage', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('image__isnull', False), ('immich_id__isnull', True)), models.Q(('image__isnull', True), ('immich_id__isnull', False)), _connector='OR'), name='image_xor_immich_id'), + ), + ] diff --git a/backend/server/adventures/migrations/0032_remove_adventureimage_image_xor_immich_id.py b/backend/server/adventures/migrations/0032_remove_adventureimage_image_xor_immich_id.py new file mode 100644 index 0000000..2ae8076 --- /dev/null +++ b/backend/server/adventures/migrations/0032_remove_adventureimage_image_xor_immich_id.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.1 on 2025-06-01 17:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0031_adventureimage_immich_id_alter_adventureimage_image_and_more'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='adventureimage', + name='image_xor_immich_id', + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 6a7b877..7e2edd6 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -786,18 +786,44 @@ class PathAndRename: class AdventureImage(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) - user_id = models.ForeignKey( - User, on_delete=models.CASCADE, default=default_user_id) + user_id = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user_id) image = ResizedImageField( force_format="WEBP", quality=75, - upload_to=PathAndRename('images/') # Use the callable class here + upload_to=PathAndRename('images/'), + blank=True, + null=True, ) + immich_id = models.CharField(max_length=200, null=True, blank=True) adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE) is_primary = models.BooleanField(default=False) + def clean(self): + from django.core.exceptions import ValidationError + + # Normalize empty values to None + has_image = bool(self.image and str(self.image).strip()) + has_immich_id = bool(self.immich_id and str(self.immich_id).strip()) + + # Exactly one must be provided + if has_image and has_immich_id: + raise ValidationError("Cannot have both image file and Immich ID. Please provide only one.") + + if not has_image and not has_immich_id: + raise ValidationError("Must provide either an image file or an Immich ID.") + + def save(self, *args, **kwargs): + # Clean empty strings to None for proper database storage + if not self.image: + self.image = None + if not self.immich_id or not str(self.immich_id).strip(): + self.immich_id = None + + self.full_clean() # This calls clean() method + super().save(*args, **kwargs) + def __str__(self): - return self.image.url + return self.image.url if self.image else f"Immich ID: {self.immich_id or 'No image'}" class Attachment(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 4c55847..e062b12 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -11,17 +11,17 @@ from geopy.distance import geodesic class AdventureImageSerializer(CustomModelSerializer): class Meta: model = AdventureImage - fields = ['id', 'image', 'adventure', 'is_primary', 'user_id'] + fields = ['id', 'image', 'adventure', 'is_primary', 'user_id', 'immich_id'] read_only_fields = ['id', 'user_id'] def to_representation(self, instance): representation = super().to_representation(instance) + public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') + public_url = public_url.replace("'", "") if instance.image: - public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') - #print(public_url) - # remove any ' from the url - public_url = public_url.replace("'", "") representation['image'] = f"{public_url}/media/{instance.image.name}" + if instance.immich_id: + representation['image'] = f"{public_url}/api/integrations/immich/get/{instance.immich_id}" return representation class AttachmentSerializer(CustomModelSerializer): diff --git a/backend/server/integrations/migrations/0002_immichintegration_copy_locally.py b/backend/server/integrations/migrations/0002_immichintegration_copy_locally.py new file mode 100644 index 0000000..cdd59cc --- /dev/null +++ b/backend/server/integrations/migrations/0002_immichintegration_copy_locally.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.1 on 2025-06-01 21:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='immichintegration', + name='copy_locally', + field=models.BooleanField(default=True, help_text='Copy image to local storage, instead of just linking to the remote URL.'), + ), + ] diff --git a/backend/server/integrations/models.py b/backend/server/integrations/models.py index 9db8a07..7b0400a 100644 --- a/backend/server/integrations/models.py +++ b/backend/server/integrations/models.py @@ -9,6 +9,7 @@ class ImmichIntegration(models.Model): api_key = models.CharField(max_length=255) user = models.ForeignKey( User, on_delete=models.CASCADE) + copy_locally = models.BooleanField(default=True, help_text="Copy image to local storage, instead of just linking to the remote URL.") id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) def __str__(self): diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index e8feb0a..74629dd 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -22,7 +22,7 @@ const dispatch = createEventDispatcher(); - let images: { id: string; image: string; is_primary: boolean }[] = []; + let images: { id: string; image: string; is_primary: boolean; immich_id: string | null }[] = []; let warningMessage: string = ''; let constrainDates: boolean = false; @@ -82,6 +82,7 @@ let fileInput: HTMLInputElement; let immichIntegration: boolean = false; + let copyImmichLocally: boolean = false; import ActivityComplete from './ActivityComplete.svelte'; import CategoryDropdown from './CategoryDropdown.svelte'; @@ -161,13 +162,19 @@ addToast('error', $t('adventures.category_fetch_error')); } // Check for Immich Integration - let res = await fetch('/api/integrations'); - if (!res.ok) { + let res = await fetch('/api/integrations/immich/'); + // If the response is not ok, we assume Immich integration is not available + if (!res.ok && res.status !== 404) { addToast('error', $t('immich.integration_fetch_error')); } else { let data = await res.json(); - if (data.immich) { + if (data.error) { + immichIntegration = false; + } else if (data.id) { immichIntegration = true; + copyImmichLocally = data.copy_locally || false; + } else { + immichIntegration = false; } } }); @@ -330,7 +337,12 @@ }); if (res.ok) { let newData = deserialize(await res.text()) as { data: { id: string; image: string } }; - let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false }; + let newImage = { + id: newData.data.id, + image: newData.data.image, + is_primary: false, + immich_id: null + }; images = [...images, newImage]; adventure.images = images; addToast('success', $t('adventures.image_upload_success')); @@ -381,7 +393,12 @@ }); if (res2.ok) { let newData = deserialize(await res2.text()) as { data: { id: string; image: string } }; - let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false }; + let newImage = { + id: newData.data.id, + image: newData.data.image, + is_primary: false, + immich_id: null + }; images = [...images, newImage]; adventure.images = images; addToast('success', $t('adventures.image_upload_success')); @@ -817,6 +834,18 @@ url = e.detail; fetchImage(); }} + {copyImmichLocally} + on:remoteImmichSaved={(e) => { + const newImage = { + id: e.detail.id, + image: e.detail.image, + is_primary: e.detail.is_primary, + immich_id: e.detail.immich_id + }; + images = [...images, newImage]; + adventure.images = images; + addToast('success', $t('adventures.image_upload_success')); + }} /> {/if} diff --git a/frontend/src/lib/components/ImmichSelect.svelte b/frontend/src/lib/components/ImmichSelect.svelte index b1d137d..27562f1 100644 --- a/frontend/src/lib/components/ImmichSelect.svelte +++ b/frontend/src/lib/components/ImmichSelect.svelte @@ -13,6 +13,7 @@ let loading = false; export let adventure: Adventure | null = null; + export let copyImmichLocally: boolean = false; const dispatch = createEventDispatcher(); @@ -45,6 +46,36 @@ return fetchAssets(immichNextURL, true); } + async function saveImmichRemoteUrl(imageId: string) { + if (!adventure) { + console.error('No adventure provided to save the image URL'); + return; + } + let res = await fetch('/api/images', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + immich_id: imageId, + adventure: adventure.id + }) + }); + if (res.ok) { + let data = await res.json(); + if (!data.image) { + console.error('No image data returned from the server'); + immichError = $t('immich.error_saving_image'); + return; + } + dispatch('remoteImmichSaved', data); + } else { + let errorData = await res.json(); + console.error('Error saving image URL:', errorData); + immichError = $t(errorData.message || 'immich.error_saving_image'); + } + } + async function fetchAssets(url: string, usingNext = false) { loading = true; try { @@ -191,7 +222,11 @@ on:click={() => { let currentDomain = window.location.origin; let fullUrl = `${currentDomain}/immich/${image.id}`; - dispatch('fetchImage', fullUrl); + if (copyImmichLocally) { + dispatch('fetchImage', fullUrl); + } else { + saveImmichRemoteUrl(image.id); + } }} > {$t('adventures.upload_image')} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 6121ba5..286a37d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -27,6 +27,7 @@ export type Adventure = { id: string; image: string; is_primary: boolean; + immich_id: string | null; }[]; visits: { id: string; @@ -242,6 +243,7 @@ export type ImmichIntegration = { id: string; server_url: string; api_key: string; + copy_locally: boolean; }; export type ImmichAlbum = { diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c10dda4..0d66669 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -699,7 +699,10 @@ "localhost_note": "Note: localhost will most likely not work unless you have setup docker networks accordingly. It is recommended to use the IP address of the server or the domain name.", "documentation": "Immich Integration Documentation", "api_key_placeholder": "Enter your Immich API key", - "need_help": "Need help setting this up? Check out the" + "need_help": "Need help setting this up? Check out the", + "copy_locally": "Copy Images Locally", + "copy_locally_desc": "Copy images to the server for offline access. Uses more disk space.", + "error_saving_image": "Error saving image" }, "recomendations": { "address": "Address", diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 955d0e1..665ec2a 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -28,7 +28,8 @@ let newImmichIntegration: ImmichIntegration = { server_url: '', api_key: '', - id: '' + id: '', + copy_locally: true }; let isMFAModalOpen: boolean = false; @@ -833,6 +834,26 @@ /> + +
+ +
+