diff --git a/docs/nginx_photos b/docs/nginx_photos index 77454b48fc0726764e8b02b524afca19c37aac60..51f5fb38035aab5013a7a610494a0afd915a082e 100644 --- a/docs/nginx_photos +++ b/docs/nginx_photos @@ -37,13 +37,17 @@ server { # Allow 2Go upload at once client_max_body_size 2G; + add_header "X-XSS-Protection" "1; mode=block"; + add_header "Content-Security-Policy" "default-src 'self' 'unsafe-inline';"; + # Django statics and media # Do not directly serve media, it must be authorized # by a Django view to check permissions - location /protected/media { + location /protected/media { internal; alias /var/www/photos/photo21/media; } + location /static { alias /var/www/photos/photo21/static; } @@ -51,5 +55,9 @@ server { location / { uwsgi_pass unix:///var/run/uwsgi/app/uwsgi_photos/socket; include /etc/nginx/uwsgi_params; + proxy_connect_timeout 600; + proxy_send_timeout 600; + proxy_read_timeout 600; + send_timeout 600; } } diff --git a/photo21/settings.py b/photo21/settings.py index e6773ec95394e18d1cba5a9883f5170601f10738..e0d25b6661697d4a827fe4ad7360998516b0feca 100644 --- a/photo21/settings.py +++ b/photo21/settings.py @@ -23,7 +23,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'CHANGE_ME' +SECRET_KEY = 'CHANGE ME' # SECURITY WARNING: don't run with debug turned on in production! DEBUG = False @@ -36,15 +36,19 @@ ALLOWED_HOSTS = [ ] # Admins receive server errors, this is useful to be notified of potential bugs +# By default MANAGERS=ADMINS, so admins also receive upload notifications ADMINS = [ ('admin', 'photos-admin@lists.crans.org'), ] -# Managers receive notifications about new photos upload -MANAGERS = [ - ('moderation', 'photos-admin@lists.crans.org'), -] +# Use secure cookies in production +SESSION_COOKIE_SECURE = not DEBUG +CSRF_COOKIE_SECURE = not DEBUG +# Remember HTTPS for 1 year +SECURE_HSTS_SECONDS = 31536000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True # Application definition @@ -144,14 +148,8 @@ PASSWORD_HASHERS = [ # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ -LANGUAGE_CODE = 'en-us' - TIME_ZONE = 'UTC' -USE_I18N = True - -USE_L10N = True - USE_TZ = True # Limit available languages to this subset @@ -185,16 +183,10 @@ LOCALE_PATHS = [os.path.join(BASE_DIR, 'photo21/locale')] FIXTURE_DIRS = [os.path.join(BASE_DIR, 'photo21/fixtures')] -# Email settings +# Do not send email during debug +# By default Django sends mails to localhost:25 without authentification if DEBUG: EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' -else: - EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', False) -EMAIL_HOST = os.getenv('EMAIL_HOST', 'localhost') -EMAIL_PORT = os.getenv('EMAIL_PORT', 25) -EMAIL_HOST_USER = os.getenv('EMAIL_USER', None) -EMAIL_HOST_PASSWORD = os.getenv('EMAIL_PASSWORD', None) # Mail will be sent from this address SERVER_EMAIL = "photos@crans.org" diff --git a/photo21/views.py b/photo21/views.py index 54299010d784dea4902c6e4cf85e7662769cc31e..90d71d5faa35fcef48fb73046328c99bbaed9145 100644 --- a/photo21/views.py +++ b/photo21/views.py @@ -17,6 +17,6 @@ class MediaAccess(LoginRequiredMixin, View): class IndexView(LoginRequiredMixin, ListView): - queryset = Gallery.objects.filter(is_public=True) + queryset = Gallery.objects.all() paginate_by = 4 template_name = "index.html" diff --git a/photologue/admin.py b/photologue/admin.py index 774ed8e89af2d4abf6c1b8d319499cc1925362c7..63cb50afb888a90aeda5e5d43622f3fba456054f 100644 --- a/photologue/admin.py +++ b/photologue/admin.py @@ -5,21 +5,26 @@ from .models import Gallery, Photo, Tag class GalleryAdmin(admin.ModelAdmin): - list_display = ('title', 'date_start', 'photo_count', 'is_public') - list_filter = ['date_start', 'is_public'] + list_display = ('title', 'date_start', 'photo_count', 'get_tags') + list_filter = ['date_start', 'tags'] date_hierarchy = 'date_start' prepopulated_fields = {'slug': ('title',)} model = Gallery - autocomplete_fields = ['photos', 'tags'] + exclude = ['photos'] + autocomplete_fields = ['tags'] search_fields = ['title', ] + def get_tags(self, obj): + return ", ".join([t.name for t in obj.tags.all()]) + get_tags.short_description = _('tags') + class PhotoAdmin(admin.ModelAdmin): list_display = ('title', 'date_taken', 'date_added', 'is_public', 'view_count', 'admin_thumbnail', 'get_owner') list_filter = ['date_added', 'is_public', 'owner'] search_fields = ['title', 'slug', 'caption'] - list_per_page = 10 + list_per_page = 25 prepopulated_fields = {'slug': ('title',)} readonly_fields = ('date_taken',) model = Photo @@ -33,6 +38,7 @@ class PhotoAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin): list_display = ('name',) search_fields = ('name',) + prepopulated_fields = {'slug': ('name',)} model = Tag diff --git a/photologue/migrations/0003_remove_gallery_is_public.py b/photologue/migrations/0003_remove_gallery_is_public.py new file mode 100644 index 0000000000000000000000000000000000000000..80920388e1f6d935967fd53dde7a7e8d01ecaa61 --- /dev/null +++ b/photologue/migrations/0003_remove_gallery_is_public.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.11 on 2022-01-30 12:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('photologue', '0002_auto_20220130_1020'), + ] + + operations = [ + migrations.RemoveField( + model_name='gallery', + name='is_public', + ), + ] diff --git a/photologue/models.py b/photologue/models.py index b7e975c634cab0baf08a232c72fb7b253617295e..fab1f3c383e492e18af91bfcf81d1a3761dc92c0 100644 --- a/photologue/models.py +++ b/photologue/models.py @@ -15,7 +15,6 @@ import exifread from django.conf import settings from django.core.exceptions import ValidationError from django.core.files.base import ContentFile -from django.core.files.storage import default_storage from django.core.validators import RegexValidator from django.db import models from django.template.defaultfilters import slugify @@ -52,13 +51,6 @@ else: fn = unicodedata.normalize('NFKD', force_str(filename)).encode('ascii', 'ignore').decode('ascii') return os.path.join('photos', fn) -# Support CACHEDIR.TAG spec for backups for ignoring cache dir. -# See http://www.brynosaurus.com/cachedir/spec.html -PHOTOLOGUE_CACHEDIRTAG = os.path.join("photos", "cache", "CACHEDIR.TAG") -if not default_storage.exists(PHOTOLOGUE_CACHEDIRTAG): - default_storage.save(PHOTOLOGUE_CACHEDIRTAG, ContentFile( - b"Signature: 8a477f597d28d172789f06886806bc55")) - # Exif Orientation values # Value 0thRow 0thColumn # 1 top left @@ -160,10 +152,6 @@ class Gallery(models.Model): verbose_name=_('tags'), blank=True, ) - is_public = models.BooleanField(_('is public'), - default=True, - help_text=_('Public galleries will be displayed ' - 'in the default views.')) photos = models.ManyToManyField('photologue.Photo', related_name='galleries', verbose_name=_('photos'), @@ -181,22 +169,13 @@ class Gallery(models.Model): def get_absolute_url(self): return reverse('photologue:pl-gallery', args=[self.slug]) - def latest(self, limit=LATEST_LIMIT, public=True): - if not limit: - limit = self.photo_count() - if public: - return self.public()[:limit] - else: - return self.photos[:limit] - - def sample(self, count=None, public=True): + def sample(self, public=True): """Return a sample of photos, ordered at random.""" - if not count: - count = 1 + count = 1 if count > self.photo_count(): count = self.photo_count() if public: - photo_set = self.public() + photo_set = self.photos.filter(is_public=True) else: photo_set = self.photos return random.sample(set(photo_set), count) @@ -204,16 +183,12 @@ class Gallery(models.Model): def photo_count(self, public=True): """Return a count of all the photos in this gallery.""" if public: - return self.public().count() + return self.photos.filter(is_public=True).count() else: return self.photos.count() photo_count.short_description = _('count') - def public(self): - """Return a queryset of all the public photos in this gallery.""" - return self.photos.filter(is_public=True) - class ImageModel(models.Model): image = models.ImageField(_('image'), @@ -315,6 +290,10 @@ class ImageModel(models.Model): setattr(self, name, result) return result else: + # When this attribute error is raised, it's usually because + # something tried to access a missing attribute on the photo. + # 99% of the time is due to a typo in the attribute name we are + # trying to access. raise AttributeError def size_exists(self, photosize): @@ -375,24 +354,14 @@ class ImageModel(models.Model): return # Save the original format im_format = im.format - # Apply effect if found - if self.effect is not None: - im = self.effect.pre_process(im) - elif photosize.effect is not None: - im = photosize.effect.pre_process(im) # Rotate if found & necessary if 'Image Orientation' in self.exif() and \ self.exif().get('Image Orientation').values[0] in IMAGE_EXIF_ORIENTATION_MAP: im = im.transpose( - IMAGE_EXIF_ORIENTATION_MAP[self.EXIF().get('Image Orientation').values[0]]) + IMAGE_EXIF_ORIENTATION_MAP[self.exif().get('Image Orientation').values[0]]) # Resize/crop image if (im.size != photosize.size and photosize.size != (0, 0)) or recreate: im = self.resize_image(im, photosize) - # Apply effect if found - if self.effect is not None: - im = self.effect.post_process(im) - elif photosize.effect is not None: - im = photosize.effect.post_process(im) # Save file im_filename = getattr(self, "get_%s_filename" % photosize.name)() try: @@ -515,10 +484,10 @@ class Photo(ImageModel): return self.title def save(self, *args, **kwargs): - # If crop_from or effect property has been changed on existing image, + # If crop_from property has been changed on existing image, # update kwargs to force image recreation in parent class current = Photo.objects.get(pk=self.pk) if self.pk else None - if current and (current.crop_from != self.crop_from or current.effect != self.effect): + if current and (current.crop_from != self.crop_from): kwargs.update(recreate=True) if self.slug is None: @@ -530,7 +499,7 @@ class Photo(ImageModel): def public_galleries(self): """Return the public galleries to which this photo belongs.""" - return self.galleries.filter(is_public=True) + return self.galleries def get_previous_in_gallery(self, gallery): """Find the neighbour of this photo in the supplied gallery. diff --git a/photologue/templates/admin/photologue/photo/change_list.html b/photologue/templates/admin/photologue/photo/change_list.html deleted file mode 100644 index 22a98d1f9044b87afd703938b2582be5d0872ab2..0000000000000000000000000000000000000000 --- a/photologue/templates/admin/photologue/photo/change_list.html +++ /dev/null @@ -1,3 +0,0 @@ -{% extends "admin/change_list.html" %} -{% load i18n %} -{# Hide upload as zip #} \ No newline at end of file diff --git a/photologue/templates/photologue/gallery_detail.html b/photologue/templates/photologue/gallery_detail.html index 1df68af1c2f7aa7b83380523a8336ea2ed31e49e..4975d548801ae26c25efe958342ad6d1f524f229 100644 --- a/photologue/templates/photologue/gallery_detail.html +++ b/photologue/templates/photologue/gallery_detail.html @@ -70,7 +70,7 @@ SPDX-License-Identifier: GPL-3.0-or-later <div class="card-body row" id="lightgallery"> {% for photo in photos %} <a class="col-6 col-md-3 mb-2 text-center" href="{{ photo.get_absolute_url }}" data-src="{{ photo.get_display_url }}" data-download-url="{{ photo.image.url }}" data-slide-name="{{ photo.id }}"> - <img src="{{ photo.get_thumbnail_url }}" loading="lazy" class="img-thumbnail p-0" alt="{{ photo.title }}{% if photo.date_taken %} - {{ photo.date_taken|date }} {{ photo.date_taken|time }}{% endif %} - {{ photo.owner.get_full_name }}{% if photo.license %} - {{ photo.license }}{% endif %}"> + <img src="{{ photo.get_thumbnail_url }}" loading="lazy" class="img-thumbnail p-0{% if not photo.is_public %} border-danger border-5{% endif %}" alt="{{ photo.title }}{% if photo.date_taken %} - {{ photo.date_taken|date }} {{ photo.date_taken|time }}{% endif %} - {{ photo.owner.get_full_name }}{% if photo.license %} - {{ photo.license }}{% endif %}"> </a> {% endfor %} </div> diff --git a/photologue/templates/photologue/photo_detail.html b/photologue/templates/photologue/photo_detail.html index c0d4c1b675ce710c8254ddab271b93f7faf39e32..ef1082bb42d0802ac947e9ac7759cc583545a324 100644 --- a/photologue/templates/photologue/photo_detail.html +++ b/photologue/templates/photologue/photo_detail.html @@ -2,7 +2,7 @@ {% comment %} SPDX-License-Identifier: GPL-3.0-or-later {% endcomment %} -{% load photologue_tags i18n %} +{% load i18n %} {% block title %}{{ object.title }}{% endblock %} @@ -13,22 +13,8 @@ SPDX-License-Identifier: GPL-3.0-or-later <p class="text-muted small">{% trans "Published" %} {{ object.date_added }}</p> </div> </div> -<div class="row"> - <div class="col-md-8"> - {% if object.caption %}<p>{{ object.caption|safe }}</p>{% endif %} - <a href="{{ object.image.url }}"> - <img src="{{ object.get_display_url }}" class="img-thumbnail" alt="{{ object.title }}"> - </a> - </div> - <div class="col-md-4"> - {% if object.public_galleries %} - <p>{% trans "This photo is found in the following galleries" %}:</p> - <ul> - {% for gallery in object.public_galleries %} - <li><a href="{{ gallery.get_absolute_url }}">{{ gallery.title }}</a></li> - {% endfor %} - </ul> - {% endif %} - </div> -</div> +{% if object.caption %}<p>{{ object.caption|safe }}</p>{% endif %} +<a href="{{ object.image.url }}"> + <img src="{{ object.get_display_url }}" class="img-thumbnail" alt="{{ object.title }}"> +</a> {% endblock %} \ No newline at end of file diff --git a/photologue/urls.py b/photologue/urls.py index e4949f870cef5046e2bcd83aff8d9a30dd07f44e..ff495337452ad526725935319ab787eb663f2a5b 100644 --- a/photologue/urls.py +++ b/photologue/urls.py @@ -1,6 +1,6 @@ from django.urls import path, re_path -from .views import (CustomGalleryDetailView, GalleryArchiveIndexView, +from .views import (GalleryDetailView, GalleryArchiveIndexView, GalleryDownload, GalleryUpload, GalleryYearArchiveView, PhotoDetailView, TagDetail) @@ -9,8 +9,8 @@ urlpatterns = [ path('tag/<slug:slug>/', TagDetail.as_view(), name='tag-detail'), path('gallery/', GalleryArchiveIndexView.as_view(), name='pl-gallery-archive'), re_path(r'^gallery/(?P<year>\d{4})/$', GalleryYearArchiveView.as_view(), name='pl-gallery-archive-year'), - path('gallery/<slug:slug>/', CustomGalleryDetailView.as_view(), name='pl-gallery'), - path('gallery/<slug:slug>/<int:owner>/', CustomGalleryDetailView.as_view(), name='pl-gallery-owner'), + path('gallery/<slug:slug>/', GalleryDetailView.as_view(), name='pl-gallery'), + path('gallery/<slug:slug>/<int:owner>/', GalleryDetailView.as_view(), name='pl-gallery-owner'), path('gallery/<slug:slug>/download/', GalleryDownload.as_view(), name='pl-gallery-download'), path('photo/<slug:slug>/', PhotoDetailView.as_view(), name='pl-photo'), path('upload/', GalleryUpload.as_view(), name='pl-gallery-upload'), diff --git a/photologue/views.py b/photologue/views.py index 77953a89f51899e1a10f680b98611c6a3d7e11a0..8d75d87b871149ccb6de8ada17a8a0633939dfff 100644 --- a/photologue/views.py +++ b/photologue/views.py @@ -24,10 +24,16 @@ from .models import Gallery, Photo, Tag class GalleryDateView(LoginRequiredMixin): - queryset = Gallery.objects.filter(is_public=True) + model = Gallery date_field = 'date_start' - uses_datetime_field = False # Fix related object access - allow_empty = True + + def get_queryset(self): + """Hide galleries with only private photos""" + qs = super().get_queryset() + if self.request.user.is_staff: + return qs + else: + return qs.filter(photos__is_public=True).distinct() class GalleryArchiveIndexView(GalleryDateView, ArchiveIndexView): @@ -39,7 +45,15 @@ class GalleryYearArchiveView(GalleryDateView, YearArchiveView): class PhotoDetailView(LoginRequiredMixin, DetailView): - queryset = Photo.objects.filter(is_public=True) + model = Photo + + def get_queryset(self): + """Non-staff members only see public photos""" + qs = super().get_queryset() + if self.request.user.is_staff: + return qs + else: + return qs.filter(is_public=True) class TagDetail(LoginRequiredMixin, DetailView): @@ -51,23 +65,25 @@ class TagDetail(LoginRequiredMixin, DetailView): """ current_tag = self.get_object().slug context = super().get_context_data(**kwargs) - context['galleries'] = Gallery.objects.filter(is_public=True) \ - .filter(tags__slug=current_tag) \ + context['galleries'] = Gallery.objects.filter(tags__slug=current_tag) \ .order_by('-date_start') return context -class CustomGalleryDetailView(LoginRequiredMixin, DetailView): +class GalleryDetailView(LoginRequiredMixin, DetailView): """ - Custom gallery detail view to filter on photo owner + Gallery detail view to filter on photo owner """ - queryset = Gallery.objects.filter(is_public=True) + model = Gallery def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - # Query with owner to reduce database lag - context['photos'] = self.object.public().select_related('owner') + # Non-staff members only see public photos + if self.request.user.is_staff: + context['photos'] = self.object.photos.all() + else: + context['photos'] = self.object.photos.filter(is_public=True) # List owners context['owners'] = [] @@ -93,7 +109,7 @@ class GalleryDownload(LoginRequiredMixin, DetailView): gallery = self.get_object() byte_data = BytesIO() zip_file = zipfile.ZipFile(byte_data, "w") - for photo in gallery.public(): + for photo in gallery.photos.filter(is_public=True): filename = os.path.basename(os.path.normpath(photo.image.path)) zip_file.write(photo.image.path, filename) zip_file.close()