Add possiblity to share gallerie with public link
All checks were successful
Docker / build (release) Successful in 8s

This commit is contained in:
krek0 2026-05-09 11:57:50 +02:00
parent 29d2153ceb
commit 13272cb9c7
8 changed files with 174 additions and 37 deletions

View file

@ -0,0 +1,24 @@
document.querySelectorAll('[data-clipboard-text]').forEach(function(btn) {
btn.addEventListener('click', function() {
var text = btn.dataset.clipboardText;
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
} else {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
var original = btn.textContent;
btn.textContent = '✓ Copied!';
btn.disabled = true;
setTimeout(function() {
btn.textContent = original;
btn.disabled = false;
}, 2000);
});
});

View file

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

View file

@ -9,14 +9,36 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
from django.views.generic import ListView, View from django.views.generic import ListView, View
from photologue.models import Gallery from photologue.models import Gallery, Photo
class MediaAccess(View): class MediaAccess(View):
def get(self, request, path): 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 from django.contrib.auth.views import redirect_to_login
return redirect_to_login(request.get_full_path()) try:
allowed_ids = set(request.get_signed_cookie("public_galleries", default="").split(","))
except Exception:
allowed_ids = set()
allowed_ids.discard("")
if not allowed_ids:
return redirect_to_login(request.get_full_path())
# Direct match (original photo file)
allowed = Photo.objects.filter(
image=path,
is_public=True,
galleries__id__in=allowed_ids,
).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 + '/',
is_public=True,
galleries__id__in=allowed_ids,
).exists()
if not allowed:
return redirect_to_login(request.get_full_path())
media_root = os.path.realpath(settings.MEDIA_ROOT) media_root = os.path.realpath(settings.MEDIA_ROOT)
file_path = os.path.realpath(os.path.join(media_root, path)) file_path = os.path.realpath(os.path.join(media_root, path))
if not file_path.startswith(media_root + os.sep): if not file_path.startswith(media_root + os.sep):

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='public_token',
field=models.UUIDField(blank=True, default=None, null=True, unique=True, verbose_name='public token'),
),
]

View file

@ -174,6 +174,13 @@ class Gallery(models.Model):
verbose_name=_("photos"), verbose_name=_("photos"),
blank=True, blank=True,
) )
public_token = models.UUIDField(
_("public token"),
null=True,
blank=True,
unique=True,
default=None,
)
class Meta: class Meta:
ordering = ["-date_start"] ordering = ["-date_start"]

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="is_staff" value="{{ request.user.is_staff|yesno:'true,false' }}">
<input type="hidden" name="user_id" value="{{ request.user.id }}"> <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="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/lightgallery.min.js' %}"></script>
<script src="{% static 'lightgallery/plugins/admin/lg-admin.js' %}"></script> <script src="{% static 'lightgallery/plugins/admin/lg-admin.js' %}"></script>
<script src="{% static 'lightgallery/plugins/hash/lg-hash.min.js' %}"></script> <script src="{% static 'lightgallery/plugins/hash/lg-hash.min.js' %}"></script>
@ -29,6 +28,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<script src="{% static 'gallery_justified.js' %}"></script> <script src="{% static 'gallery_justified.js' %}"></script>
<script src="{% static 'gallery_detail.js' %}"></script> <script src="{% static 'gallery_detail.js' %}"></script>
<script src="{% static 'sweetalert.js' %}"></script> <script src="{% static 'sweetalert.js' %}"></script>
<script src="{% static 'copy-button.js' %}"></script>
{% if not request.user.is_authenticated %}<style>#lg-download, #lg-admin, #lg-delete { display: none !important; }</style>{% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@ -45,6 +46,18 @@ SPDX-License-Identifier: GPL-3.0-or-later
</h1> </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 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">{{ gallery.photo_private_count }} photos censurées</p>{% endif %} {% if request.user.is_staff and gallery.photo_private_count %}<p class="text-danger small">{{ gallery.photo_private_count }} photos censurées</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>
{% 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>
{% endif %}
{% endif %}
{% if gallery.tags.all %} {% if gallery.tags.all %}
<p class="text-muted"> <p class="text-muted">
Tags : {% for tag in gallery.tags.all %} Tags : {% for tag in gallery.tags.all %}
@ -55,6 +68,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if gallery.description %}<p>{{ gallery.description|linebreaksbr }}</p>{% endif %} {% if gallery.description %}<p>{{ gallery.description|linebreaksbr }}</p>{% endif %}
<div class="card"> <div class="card">
{% if owners %}
<div class="card-header pb-0 border-bottom-0"> <div class="card-header pb-0 border-bottom-0">
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li class="nav-item"> <li class="nav-item">
@ -73,6 +87,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
{% endif %}
<div class="card-body p-0" id="lightgallery"> <div class="card-body p-0" id="lightgallery">
{% for photo in photos %} {% 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 }}">

View file

@ -9,6 +9,8 @@ from .views import (
GalleryArchiveIndexView, GalleryArchiveIndexView,
GalleryDetailView, GalleryDetailView,
GalleryDownload, GalleryDownload,
GalleryPublicView,
GalleryTokenView,
GalleryUpload, GalleryUpload,
GalleryYearArchiveView, GalleryYearArchiveView,
PhotoDeleteView, PhotoDeleteView,
@ -44,4 +46,6 @@ urlpatterns = [
path("photo/<int:pk>/report/", PhotoReportView.as_view(), name="pl-photo-report"), 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("photo/<int:pk>/uncensor/", PhotoUncensorView.as_view(), name="pl-photo-uncensor"),
path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"), path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"),
path("share/<uuid:token>/", GalleryPublicView.as_view(), name="pl-gallery-public"),
path("gallery/<slug:slug>/token/", GalleryTokenView.as_view(), name="pl-gallery-token"),
] ]

View file

@ -3,24 +3,23 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import os import os
import uuid
import zipfile import zipfile
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.core.mail import mail_admins from django.core.mail import mail_admins
from django.db import transaction from django.db import transaction
from django.http import HttpResponse from django.http import HttpResponse, JsonResponse
from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import redirect from django.urls import reverse, reverse_lazy
from django.urls import reverse_lazy
from django.utils.text import slugify from django.utils.text import slugify
from django.views import View
from django.views.generic.dates import ArchiveIndexView, YearArchiveView from django.views.generic.dates import ArchiveIndexView, YearArchiveView
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from django.views.generic.edit import DeleteView, FormView from django.views.generic.edit import DeleteView, FormView
from django.conf import settings
from PIL import Image from PIL import Image
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -150,13 +149,30 @@ class TagDetail(LoginRequiredMixin, DetailView):
return context return context
class GalleryDetailView(LoginRequiredMixin, DetailView): def _allowed_gallery_ids(request):
try:
ids = set(request.get_signed_cookie("public_galleries", default="").split(","))
except Exception:
ids = set()
ids.discard("")
return ids
class GalleryDetailView(DetailView):
""" """
Gallery detail view to filter on photo owner Gallery detail view to filter on photo owner
""" """
model = Gallery model = Gallery
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
gallery = self.get_object()
if str(gallery.id) not in _allowed_gallery_ids(request):
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): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -185,46 +201,71 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
if "owner" in self.kwargs: if "owner" in self.kwargs:
context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"]) context["photos"] = context["photos"].filter(owner__id=self.kwargs["owner"])
# Increment the photo view count if self.object.public_token:
public_path = reverse("photologue:pl-gallery-public", args=[self.object.public_token])
context["public_url"] = self.request.build_absolute_uri(public_path)
context["photos"].update(view_count=F("view_count") + 1) context["photos"].update(view_count=F("view_count") + 1)
return context return context
class GalleryDownload(LoginRequiredMixin, DetailView): class GalleryDownload(DetailView):
### IN FUTURE, PUT IT as Django Task
model = Gallery model = Gallery
def dispatch(self, request, *args, **kwargs):
if not request.user.is_authenticated:
gallery = self.get_object()
if str(gallery.id) not in _allowed_gallery_ids(request):
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): def get(self, request, *args, **kwargs):
"""
Download a zip file of the gallery on GET request.
"""
# Create zip file with pictures
gallery = self.get_object() gallery = self.get_object()
buffer = BytesIO()
gallery_year = os.path.join("/photos/", str(gallery.date_start.year)) with zipfile.ZipFile(buffer, "w") as zf:
gallery_zip = os.path.join(gallery_year, (gallery.slug + ".zip"))
with open(settings.MEDIA_ROOT + gallery_zip, "wb") as zip_bytes:
zip_file = zipfile.ZipFile(zip_bytes, "w")
for photo in gallery.photos.filter(is_public=True): for photo in gallery.photos.filter(is_public=True):
filename = os.path.basename(os.path.normpath(photo.image.path)) filename = os.path.basename(photo.image.name)
zip_file.write(photo.image.path, filename) zf.write(photo.image.path, filename)
zip_file.close() buffer.seek(0)
response = HttpResponse(buffer, content_type="application/zip")
response["Content-Disposition"] = f'attachment; filename="{gallery.slug}.zip"'
return response
# Return the path to it
return redirect( class GalleryPublicView(View):
(settings.MEDIA_URL + str(gallery_zip)).replace("\\", "/") def get(self, request, token):
) # windows fix gallery = get_object_or_404(Gallery, public_token=token)
# Return zip file response = redirect("photologue:pl-gallery", slug=gallery.slug)
if not request.user.is_authenticated:
try:
existing = set(request.get_signed_cookie("public_galleries", default="").split(","))
except Exception:
existing = set()
existing.discard("")
existing.add(str(gallery.id))
response.set_signed_cookie(
"public_galleries", ",".join(existing),
max_age=86400 * 30, httponly=True, samesite="Lax",
)
return response
# response = HttpResponse(
# byte_data.getvalue(), content_type="application/x-zip-compressed" class GalleryTokenView(LoginRequiredMixin, View):
# ) def post(self, request, slug):
# response["Content-Disposition"] = f"attachment; filename={gallery.slug}.zip" if not request.user.is_staff:
# return response from django.core.exceptions import PermissionDenied
raise PermissionDenied
gallery = get_object_or_404(Gallery, slug=slug)
action = request.POST.get("action")
if action == "generate":
gallery.public_token = uuid.uuid4()
gallery.save()
elif action == "revoke":
gallery.public_token = None
gallery.save()
return redirect(reverse("photologue:pl-gallery", args=[slug]))
class GalleryUpload(PermissionRequiredMixin, FormView): class GalleryUpload(PermissionRequiredMixin, FormView):