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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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):
|
||||
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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
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):
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue