diff --git a/photo21/static/copy-button.js b/photo21/static/copy-button.js
new file mode 100644
index 0000000..91f7ec8
--- /dev/null
+++ b/photo21/static/copy-button.js
@@ -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);
+ });
+});
diff --git a/photo21/templates/base.html b/photo21/templates/base.html
index 91f63cb..ccd17c2 100644
--- a/photo21/templates/base.html
+++ b/photo21/templates/base.html
@@ -37,6 +37,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
+ {% if request.user.is_authenticated %}
-
{% url 'photologue:pl-gallery-archive' as url %}
{% trans 'Galleries' %}
@@ -52,6 +53,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% trans 'Manage' %}
{% endif %}
+ {% endif %}
{% get_available_languages as LANGUAGES %}
diff --git a/photo21/views.py b/photo21/views.py
index bedd2a1..1e35953 100644
--- a/photo21/views.py
+++ b/photo21/views.py
@@ -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):
diff --git a/photologue/migrations/0009_alter_photo_options_gallery_public_token.py b/photologue/migrations/0009_alter_photo_options_gallery_public_token.py
new file mode 100644
index 0000000..f25d6e5
--- /dev/null
+++ b/photologue/migrations/0009_alter_photo_options_gallery_public_token.py
@@ -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'),
+ ),
+ ]
diff --git a/photologue/models.py b/photologue/models.py
index e304b5c..f5fb5b8 100644
--- a/photologue/models.py
+++ b/photologue/models.py
@@ -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"]
diff --git a/photologue/templates/photologue/gallery_detail.html b/photologue/templates/photologue/gallery_detail.html
index 5364548..ac63407 100755
--- a/photologue/templates/photologue/gallery_detail.html
+++ b/photologue/templates/photologue/gallery_detail.html
@@ -20,7 +20,6 @@ SPDX-License-Identifier: GPL-3.0-or-later
-
@@ -29,6 +28,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
+
+{% if not request.user.is_authenticated %}{% endif %}
{% endblock %}
{% block content %}
@@ -45,6 +46,18 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if gallery.date_start %}{{ gallery.date_start }}{% if gallery.date_end and gallery.date_end != gallery.date_start %} {% trans "to" %} {{ gallery.date_end }}{% endif %}
{% endif %}
{% if request.user.is_staff and gallery.photo_private_count %}{{ gallery.photo_private_count }} photos censurées
{% endif %}
+{% if request.user.is_staff %}
+ {% if public_url %}
+
+ {% trans "Public link:" %}
+
+
+
+
+ {% else %}
+
+ {% endif %}
+{% endif %}
{% if 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 %}
{{ gallery.description|linebreaksbr }}
{% endif %}
+ {% if owners %}
+ {% endif %}
{% for photo in photos %}
diff --git a/photologue/urls.py b/photologue/urls.py
index 53379e4..0f6e397 100644
--- a/photologue/urls.py
+++ b/photologue/urls.py
@@ -9,6 +9,8 @@ from .views import (
GalleryArchiveIndexView,
GalleryDetailView,
GalleryDownload,
+ GalleryPublicView,
+ GalleryTokenView,
GalleryUpload,
GalleryYearArchiveView,
PhotoDeleteView,
@@ -44,4 +46,6 @@ urlpatterns = [
path("photo//report/", PhotoReportView.as_view(), name="pl-photo-report"),
path("photo//uncensor/", PhotoUncensorView.as_view(), name="pl-photo-uncensor"),
path("upload/", GalleryUpload.as_view(), name="pl-gallery-upload"),
+ path("share//", GalleryPublicView.as_view(), name="pl-gallery-public"),
+ path("gallery//token/", GalleryTokenView.as_view(), name="pl-gallery-token"),
]
diff --git a/photologue/views.py b/photologue/views.py
index 0cf9ab3..f24dae5 100644
--- a/photologue/views.py
+++ b/photologue/views.py
@@ -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):