Add configurable per-gallery public access

This commit is contained in:
krek0 2026-05-11 15:49:46 +02:00
parent 72e9344102
commit 1cdd1dce26
10 changed files with 119 additions and 45 deletions

View file

@ -37,6 +37,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% if request.user.is_authenticated %}
<li class="nav-item">
{% url 'photologue:pl-gallery-archive' as url %}
<a class="nav-link {% if request.path_info == url %}active{% endif %}" href="{{ url }}">{% trans 'Galleries' %}</a>
@ -52,6 +53,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="nav-link" href="{% url 'admin:index' %}">{% trans 'Manage' %}</a>
</li>
{% endif %}
{% endif %}
</ul>
<ul class="navbar-nav">
{% get_available_languages as LANGUAGES %}

View file

@ -9,13 +9,29 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import FileResponse, Http404
from django.views.generic import ListView, View
from photologue.models import Gallery
from photologue.models import Gallery, Photo
class MediaAccess(View):
def get(self, request, path):
if not request.user.is_authenticated and not request.session.get('public_gallery_access'):
if not request.user.is_authenticated:
from django.contrib.auth.views import redirect_to_login
try:
# Direct match (original photo file)
allowed = Photo.objects.filter(
image=path,
galleries__is_public=True,
).exists()
# Cache files (thumbnails/display) are derived from original photos
if not allowed and '/cache/' in path:
original_dir = os.path.dirname(os.path.dirname(path))
allowed = Photo.objects.filter(
image__startswith=original_dir + '/',
galleries__is_public=True,
).exists()
except Exception:
return redirect_to_login(request.get_full_path())
if not allowed:
return redirect_to_login(request.get_full_path())
media_root = os.path.realpath(settings.MEDIA_ROOT)
file_path = os.path.realpath(os.path.join(media_root, path))

View file

@ -3,7 +3,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.contrib.admin import EmptyFieldListFilter
from django.utils.translation import gettext_lazy as _
from .models import Gallery, Photo, Tag
@ -11,7 +10,7 @@ from .models import Gallery, Photo, Tag
class GalleryAdmin(admin.ModelAdmin):
list_display = ("title", "date_start", "photo_count", "get_tags")
list_filter = ["date_start", "tags", ("public_token", EmptyFieldListFilter)]
list_filter = ["date_start", "tags", "is_public"]
date_hierarchy = "date_start"
prepopulated_fields = {"slug": ("title",)}
model = Gallery

View file

@ -403,21 +403,17 @@ msgstr "au"
msgid "censored photos"
msgstr "photos censurées"
#: photologue/templates/photologue/gallery_detail.html:53
msgid "Public link:"
msgstr ""
msgid "Make public"
msgstr "Rendre publique"
#: photologue/templates/photologue/gallery_detail.html:55
msgid "Copy"
msgstr ""
msgid "Make private"
msgstr "Rendre privée"
#: photologue/templates/photologue/gallery_detail.html:56
msgid "Revoke"
msgstr ""
msgid "Public, anyone with the link can view this gallery."
msgstr "Publique, n'importe qui avec le lien peut voir cette galerie."
#: photologue/templates/photologue/gallery_detail.html:59
msgid "Generate public link"
msgstr ""
msgid "Private, login required to view."
msgstr "Privée, connexion requise pour voir."
#: photologue/templates/photologue/gallery_detail.html:78
msgid "All pictures"

View file

@ -0,0 +1,22 @@
# Generated by Django 5.2.13 on 2026-04-20 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('photologue', '0008_regen_thumbnails'),
]
operations = [
migrations.AlterModelOptions(
name='photo',
options={'get_latest_by': 'date_added', 'ordering': ['title'], 'verbose_name': 'photo', 'verbose_name_plural': 'photos'},
),
migrations.AddField(
model_name='gallery',
name='is_public',
field=models.BooleanField(default=False, help_text='Public galleries can be accessed without being logged in.', verbose_name='is public'),
),
]

View file

@ -174,6 +174,11 @@ class Gallery(models.Model):
verbose_name=_("photos"),
blank=True,
)
is_public = models.BooleanField(
_("is public"),
default=False,
help_text=_("Public galleries can be accessed without being logged in."),
)
class Meta:
ordering = ["-date_start"]

View file

@ -13,7 +13,6 @@ class lgAdmin {
this.isStaff = document.querySelector('[name=is_staff]').value === "true";
this.userId = document.querySelector('[name=user_id]').value;
this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true";
this.guestMode = document.querySelector('[name=guest_mode]').value === "true";
this.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
this.photoId = 0;
return this;

View file

@ -20,7 +20,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
<input type="hidden" name="is_staff" value="{{ request.user.is_staff|yesno:'true,false' }}">
<input type="hidden" name="user_id" value="{{ request.user.id }}">
<input type="hidden" name="can_resolve_censorship" value="{{ can_resolve_censorship|yesno:'true,false' }}">
<input type="hidden" name="guest_mode" value="{{ guest_mode|yesno:'true,false' }}">
<script src="{% static 'lightgallery/lightgallery.min.js' %}"></script>
<script src="{% static 'lightgallery/plugins/admin/lg-admin.js' %}"></script>
<script src="{% static 'lightgallery/plugins/hash/lg-hash.min.js' %}"></script>
@ -29,6 +28,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script src="{% static 'gallery_justified.js' %}"></script>
<script src="{% static 'gallery_detail.js' %}"></script>
<script src="{% static 'sweetalert.js' %}"></script>
{% if not request.user.is_authenticated %}<style>#lg-download, #lg-admin, #lg-delete { display: none !important; }</style>{% endif %}
{% endblock %}
{% block content %}
@ -45,18 +45,22 @@ SPDX-License-Identifier: GPL-3.0-or-later
</h1>
{% if gallery.date_start %}<p class="text-muted small">{{ gallery.date_start }}{% if gallery.date_end and gallery.date_end != gallery.date_start %} {% trans "to" %} {{ gallery.date_end }}{% endif %}</p>{% endif %}
{% if request.user.is_staff and gallery.photo_private_count %}<p class="text-danger small" id="private-count-line"><span id="private-count">{{ gallery.photo_private_count }}</span> {% trans "censored photos" %}</p>{% endif %}
{% if request.user.is_staff %}
{% if public_url %}
<div class="alert alert-secondary py-1 d-flex align-items-center gap-2 small" role="alert">
<span>{% trans "Public link:" %}</span>
<input type="text" class="form-control form-control-sm" id="public-link-input" value="{{ public_url }}" readonly style="max-width:400px">
<button class="btn btn-outline-secondary btn-sm" data-clipboard-text="{{ public_url }}">{% trans "Copy" %}</button>
<form method="post" action="{% url 'photologue:pl-gallery-token' gallery.slug %}" class="mb-0">{% csrf_token %}<input type="hidden" name="action" value="revoke"><button type="submit" class="btn btn-outline-danger btn-sm">{% trans "Revoke" %}</button></form>
</div>
<div class="d-flex align-items-center gap-2 mb-2">
{% if request.user.is_staff %}
<form method="post" action="{% url 'photologue:pl-gallery-toggle-public' gallery.slug %}" class="mb-0">{% csrf_token %}
{% if gallery.is_public %}
<button type="submit" class="btn btn-outline-warning btn-sm">{% trans "Make private" %}</button>
{% else %}
<form method="post" action="{% url 'photologue:pl-gallery-token' gallery.slug %}" class="mb-2">{% csrf_token %}<input type="hidden" name="action" value="generate"><button type="submit" class="btn btn-outline-primary btn-sm">{% trans "Generate public link" %}</button></form>
<button type="submit" class="btn btn-outline-success btn-sm">{% trans "Make public" %}</button>
{% endif %}
{% endif %}
</form>
{% endif %}
{% if gallery.is_public %}
<p class="small text-muted mb-0">{% trans "Public, anyone with the link can view this gallery." %}</p>
{% else %}
<p class="small text-muted mb-0">{% trans "Private, login required to view." %}</p>
{% endif %}
</div>
{% if gallery.tags.all %}
<p class="text-muted">
Tags : {% for tag in gallery.tags.all %}
@ -87,7 +91,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
</div>
<div class="card-body p-0" id="lightgallery">
{% for photo in photos %}
<a class="photo-item" href="{{ photo.get_absolute_url }}" data-src="{{ photo.get_display_url}}" data-download-url="{{ photo.image.url }}" data-slide-name="{{ photo.id }}" data-owner-id="{{ photo.owner.id }}" data-is-public="{{ photo.is_public|yesno:'true,false' }}" data-width="{{ photo.image_width|default:1 }}" data-height="{{ photo.image_height|default:1 }}">
<a class="photo-item" href="{{ photo.get_absolute_url }}" data-src="{{ photo.get_display_url }}" data-download-url="{{ photo.image.url }}" data-slide-name="{{ photo.id }}" data-owner-id="{{ photo.owner.id }}" data-is-public="{{ photo.is_public|yesno:'true,false' }}" data-width="{{ photo.image_width|default:1 }}" data-height="{{ photo.image_height|default:1 }}">
<img src="{{ photo.get_thumbnail_url }}" data-lazy="{{ photo.get_thumbnail_url }}" class="{% if not photo.is_public %}photo-private{% endif %}" alt="{{ photo.title }}{% if photo.date_taken %} - {{ photo.date_taken|date }} {{ photo.date_taken|time }}{% endif %}{% if photo.owner.get_full_name %} - {{ photo.owner.get_full_name }}{% else %} - {{ photo.owner.username }}{% endif %}{% if photo.license %} - {{ photo.license }}{% endif %}{% if not photo.is_public %} - !PRIVATE!{% endif %}">
</a>
{% endfor %}

View file

@ -9,6 +9,7 @@ from .views import (
GalleryArchiveIndexView,
GalleryDetailView,
GalleryDownload,
GalleryPublicToggleView,
GalleryUpload,
GalleryYearArchiveView,
PhotoDeleteView,
@ -44,4 +45,5 @@ urlpatterns = [
path("photo/<int:pk>/report/", PhotoReportView.as_view(), name="pl-photo-report"),
path("photo/<int:pk>/uncensor/", PhotoUncensorView.as_view(), name="pl-photo-uncensor"),
path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"),
path("gallery/<slug:slug>/toggle-public/", GalleryPublicToggleView.as_view(), name="pl-gallery-toggle-public"),
]

View file

@ -4,23 +4,23 @@
import os
import zipfile
from io import BytesIO
from pathlib import Path
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.mail import mail_admins
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse
from django.http import JsonResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse, reverse_lazy
from django.utils.text import slugify
from django.views import View
from django.views.generic.dates import ArchiveIndexView, YearArchiveView
from django.views.generic.detail import DetailView
from django.views.generic.edit import DeleteView, FormView
from django.conf import settings
from PIL import Image
from django.contrib.auth import get_user_model
@ -93,10 +93,18 @@ class PhotoDeleteView(LoginRequiredMixin, DeleteView):
return reverse_lazy("photologue:pl-gallery", args=[slug])
class PhotoReportView(LoginRequiredMixin, DetailView):
class PhotoReportView(DetailView):
model = Photo
template_name = "photologue/photo_confirm_report.html"
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
photo = self.get_object()
if not photo.galleries.filter(is_public=True).exists():
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(request.get_full_path())
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""
Make photo private on POST.
@ -115,9 +123,10 @@ class PhotoReportView(LoginRequiredMixin, DetailView):
url = request.build_absolute_uri(url)
# Send mail to managers
reporter = request.user.username if request.user.is_authenticated else "Anonymous (public link)"
mail_admins(
subject=f"Abuse report for photo id {photo.pk}",
message=f"{self.request.user.username} reported an abuse for `{photo.title}`: {url}#lg=1&slide={photo.pk}",
message=f"{reporter} reported an abuse for `{photo.title}`: {url}#lg=1&slide={photo.pk}",
)
# Redirect to gallery
@ -150,13 +159,21 @@ class TagDetail(LoginRequiredMixin, DetailView):
return context
class GalleryDetailView(LoginRequiredMixin, DetailView):
class GalleryDetailView(DetailView):
"""
Gallery detail view to filter on photo owner
"""
model = Gallery
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
self.object = self.get_object()
if not self.object.is_public:
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(request.get_full_path())
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -192,10 +209,18 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
return context
class GalleryDownload(LoginRequiredMixin, DetailView):
class GalleryDownload(DetailView):
### IN FUTURE, PUT IT as Django Task
model = Gallery
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
self.object = self.get_object()
if not self.object.is_public:
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(request.get_full_path())
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
"""
Download a zip file of the gallery on GET request.
@ -218,13 +243,17 @@ class GalleryDownload(LoginRequiredMixin, DetailView):
return redirect(
(settings.MEDIA_URL + str(gallery_zip)).replace("\\", "/")
) # windows fix
# Return zip file
# response = HttpResponse(
# byte_data.getvalue(), content_type="application/x-zip-compressed"
# )
# response["Content-Disposition"] = f"attachment; filename={gallery.slug}.zip"
# return response
class GalleryPublicToggleView(LoginRequiredMixin, View):
def post(self, request, slug):
if not request.user.is_staff:
from django.core.exceptions import PermissionDenied
raise PermissionDenied
gallery = get_object_or_404(Gallery, slug=slug)
gallery.is_public = not gallery.is_public
gallery.save()
return redirect(reverse("photologue:pl-gallery", args=[slug]))
class GalleryUpload(PermissionRequiredMixin, FormView):