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()