mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-07-18 20:39:36 +02:00
feat: enhance Immich integration with local copy option and validation for image handling
This commit is contained in:
parent
f95afdc35c
commit
06787bccf6
12 changed files with 214 additions and 37 deletions
|
@ -8,41 +8,35 @@ http {
|
||||||
sendfile on;
|
sendfile on;
|
||||||
keepalive_timeout 65;
|
keepalive_timeout 65;
|
||||||
client_max_body_size 100M;
|
client_max_body_size 100M;
|
||||||
|
# The backend is running in the same container, so reference localhost
|
||||||
upstream django {
|
upstream django {
|
||||||
server 127.0.0.1:8000;
|
server 127.0.0.1:8000; # Use localhost to point to Gunicorn running internally
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://django;
|
proxy_pass http://django; # Forward to the upstream block
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias /code/staticfiles/;
|
alias /code/staticfiles/; # Serve static files directly
|
||||||
}
|
}
|
||||||
|
# Serve protected media files with X-Accel-Redirect
|
||||||
# Special handling for PDF files with CSP headers
|
location /protectedMedia/ {
|
||||||
location ~ ^/protectedMedia/(.*)\.pdf$ {
|
internal; # Only internal requests are allowed
|
||||||
internal;
|
alias /code/media/; # This should match Django MEDIA_ROOT
|
||||||
alias /code/media/$1.pdf;
|
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 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-Content-Type-Options nosniff always;
|
||||||
add_header X-Frame-Options SAMEORIGIN always;
|
add_header X-Frame-Options SAMEORIGIN always;
|
||||||
add_header Content-Disposition "inline" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
}
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
# General protected media files (non-PDF)
|
|
||||||
location ~ ^/protectedMedia/(.*)$ {
|
|
||||||
internal;
|
|
||||||
alias /code/media/$1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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',
|
||||||
|
),
|
||||||
|
]
|
|
@ -786,18 +786,44 @@ class PathAndRename:
|
||||||
|
|
||||||
class AdventureImage(models.Model):
|
class AdventureImage(models.Model):
|
||||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||||
user_id = models.ForeignKey(
|
user_id = models.ForeignKey(User, on_delete=models.CASCADE, default=default_user_id)
|
||||||
User, on_delete=models.CASCADE, default=default_user_id)
|
|
||||||
image = ResizedImageField(
|
image = ResizedImageField(
|
||||||
force_format="WEBP",
|
force_format="WEBP",
|
||||||
quality=75,
|
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)
|
adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE)
|
||||||
is_primary = models.BooleanField(default=False)
|
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):
|
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):
|
class Attachment(models.Model):
|
||||||
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||||
|
|
|
@ -11,17 +11,17 @@ from geopy.distance import geodesic
|
||||||
class AdventureImageSerializer(CustomModelSerializer):
|
class AdventureImageSerializer(CustomModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AdventureImage
|
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']
|
read_only_fields = ['id', 'user_id']
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def to_representation(self, instance):
|
||||||
representation = super().to_representation(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:
|
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}"
|
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
|
return representation
|
||||||
|
|
||||||
class AttachmentSerializer(CustomModelSerializer):
|
class AttachmentSerializer(CustomModelSerializer):
|
||||||
|
|
|
@ -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.'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -9,6 +9,7 @@ class ImmichIntegration(models.Model):
|
||||||
api_key = models.CharField(max_length=255)
|
api_key = models.CharField(max_length=255)
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User, on_delete=models.CASCADE)
|
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)
|
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
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 warningMessage: string = '';
|
||||||
let constrainDates: boolean = false;
|
let constrainDates: boolean = false;
|
||||||
|
|
||||||
|
@ -82,6 +82,7 @@
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
let fileInput: HTMLInputElement;
|
||||||
let immichIntegration: boolean = false;
|
let immichIntegration: boolean = false;
|
||||||
|
let copyImmichLocally: boolean = false;
|
||||||
|
|
||||||
import ActivityComplete from './ActivityComplete.svelte';
|
import ActivityComplete from './ActivityComplete.svelte';
|
||||||
import CategoryDropdown from './CategoryDropdown.svelte';
|
import CategoryDropdown from './CategoryDropdown.svelte';
|
||||||
|
@ -161,13 +162,19 @@
|
||||||
addToast('error', $t('adventures.category_fetch_error'));
|
addToast('error', $t('adventures.category_fetch_error'));
|
||||||
}
|
}
|
||||||
// Check for Immich Integration
|
// Check for Immich Integration
|
||||||
let res = await fetch('/api/integrations');
|
let res = await fetch('/api/integrations/immich/');
|
||||||
if (!res.ok) {
|
// 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'));
|
addToast('error', $t('immich.integration_fetch_error'));
|
||||||
} else {
|
} else {
|
||||||
let data = await res.json();
|
let data = await res.json();
|
||||||
if (data.immich) {
|
if (data.error) {
|
||||||
|
immichIntegration = false;
|
||||||
|
} else if (data.id) {
|
||||||
immichIntegration = true;
|
immichIntegration = true;
|
||||||
|
copyImmichLocally = data.copy_locally || false;
|
||||||
|
} else {
|
||||||
|
immichIntegration = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -330,7 +337,12 @@
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
let newData = deserialize(await res.text()) as { data: { id: string; image: string } };
|
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];
|
images = [...images, newImage];
|
||||||
adventure.images = images;
|
adventure.images = images;
|
||||||
addToast('success', $t('adventures.image_upload_success'));
|
addToast('success', $t('adventures.image_upload_success'));
|
||||||
|
@ -381,7 +393,12 @@
|
||||||
});
|
});
|
||||||
if (res2.ok) {
|
if (res2.ok) {
|
||||||
let newData = deserialize(await res2.text()) as { data: { id: string; image: string } };
|
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];
|
images = [...images, newImage];
|
||||||
adventure.images = images;
|
adventure.images = images;
|
||||||
addToast('success', $t('adventures.image_upload_success'));
|
addToast('success', $t('adventures.image_upload_success'));
|
||||||
|
@ -817,6 +834,18 @@
|
||||||
url = e.detail;
|
url = e.detail;
|
||||||
fetchImage();
|
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}
|
{/if}
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
let loading = false;
|
let loading = false;
|
||||||
|
|
||||||
export let adventure: Adventure | null = null;
|
export let adventure: Adventure | null = null;
|
||||||
|
export let copyImmichLocally: boolean = false;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
@ -45,6 +46,36 @@
|
||||||
return fetchAssets(immichNextURL, true);
|
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) {
|
async function fetchAssets(url: string, usingNext = false) {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
|
@ -191,7 +222,11 @@
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
let currentDomain = window.location.origin;
|
let currentDomain = window.location.origin;
|
||||||
let fullUrl = `${currentDomain}/immich/${image.id}`;
|
let fullUrl = `${currentDomain}/immich/${image.id}`;
|
||||||
dispatch('fetchImage', fullUrl);
|
if (copyImmichLocally) {
|
||||||
|
dispatch('fetchImage', fullUrl);
|
||||||
|
} else {
|
||||||
|
saveImmichRemoteUrl(image.id);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{$t('adventures.upload_image')}
|
{$t('adventures.upload_image')}
|
||||||
|
|
|
@ -27,6 +27,7 @@ export type Adventure = {
|
||||||
id: string;
|
id: string;
|
||||||
image: string;
|
image: string;
|
||||||
is_primary: boolean;
|
is_primary: boolean;
|
||||||
|
immich_id: string | null;
|
||||||
}[];
|
}[];
|
||||||
visits: {
|
visits: {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -242,6 +243,7 @@ export type ImmichIntegration = {
|
||||||
id: string;
|
id: string;
|
||||||
server_url: string;
|
server_url: string;
|
||||||
api_key: string;
|
api_key: string;
|
||||||
|
copy_locally: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ImmichAlbum = {
|
export type ImmichAlbum = {
|
||||||
|
|
|
@ -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.",
|
"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",
|
"documentation": "Immich Integration Documentation",
|
||||||
"api_key_placeholder": "Enter your Immich API key",
|
"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": {
|
"recomendations": {
|
||||||
"address": "Address",
|
"address": "Address",
|
||||||
|
|
|
@ -28,7 +28,8 @@
|
||||||
let newImmichIntegration: ImmichIntegration = {
|
let newImmichIntegration: ImmichIntegration = {
|
||||||
server_url: '',
|
server_url: '',
|
||||||
api_key: '',
|
api_key: '',
|
||||||
id: ''
|
id: '',
|
||||||
|
copy_locally: true
|
||||||
};
|
};
|
||||||
|
|
||||||
let isMFAModalOpen: boolean = false;
|
let isMFAModalOpen: boolean = false;
|
||||||
|
@ -833,6 +834,26 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toggle for copy_locally -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label cursor-pointer justify-start gap-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={newImmichIntegration.copy_locally}
|
||||||
|
class="toggle toggle-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span class="label-text font-medium">
|
||||||
|
{$t('immich.copy_locally') || 'Copy Locally'}
|
||||||
|
</span>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{$t('immich.copy_locally_desc') ||
|
||||||
|
'If enabled, files will be copied locally.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button on:click={enableImmichIntegration} class="btn btn-primary w-full">
|
<button on:click={enableImmichIntegration} class="btn btn-primary w-full">
|
||||||
{!immichIntegration?.id
|
{!immichIntegration?.id
|
||||||
? `🔗 ${$t('immich.enable_integration')}`
|
? `🔗 ${$t('immich.enable_integration')}`
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue