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>
<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,14 +9,36 @@ 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
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)
file_path = os.path.realpath(os.path.join(media_root, path))
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"),
blank=True,
)
public_token = models.UUIDField(
_("public token"),
null=True,
blank=True,
unique=True,
default=None,
)
class Meta:
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="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,8 @@ 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>
<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 %}
{% block content %}
@ -45,6 +46,18 @@ 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">{{ 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 %}
<p class="text-muted">
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 %}
<div class="card">
{% if owners %}
<div class="card-header pb-0 border-bottom-0">
<ul class="nav nav-tabs">
<li class="nav-item">
@ -73,6 +87,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endfor %}
</ul>
</div>
{% endif %}
<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 }}">

View file

@ -9,6 +9,8 @@ from .views import (
GalleryArchiveIndexView,
GalleryDetailView,
GalleryDownload,
GalleryPublicView,
GalleryTokenView,
GalleryUpload,
GalleryYearArchiveView,
PhotoDeleteView,
@ -44,4 +46,6 @@ 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("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
import os
import uuid
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.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.http import HttpResponse, JsonResponse
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
@ -150,13 +149,30 @@ class TagDetail(LoginRequiredMixin, DetailView):
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
"""
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):
context = super().get_context_data(**kwargs)
@ -185,46 +201,71 @@ class GalleryDetailView(LoginRequiredMixin, DetailView):
if "owner" in self.kwargs:
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)
return context
class GalleryDownload(LoginRequiredMixin, DetailView):
### IN FUTURE, PUT IT as Django Task
class GalleryDownload(DetailView):
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):
"""
Download a zip file of the gallery on GET request.
"""
# Create zip file with pictures
gallery = self.get_object()
gallery_year = os.path.join("/photos/", str(gallery.date_start.year))
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")
buffer = BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
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()
filename = os.path.basename(photo.image.name)
zf.write(photo.image.path, filename)
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(
(settings.MEDIA_URL + str(gallery_zip)).replace("\\", "/")
) # windows fix
# Return zip file
class GalleryPublicView(View):
def get(self, request, token):
gallery = get_object_or_404(Gallery, public_token=token)
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"
# )
# response["Content-Disposition"] = f"attachment; filename={gallery.slug}.zip"
# return response
class GalleryTokenView(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)
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):