Add possiblity to share gallerie with public link
All checks were successful
Docker / build (release) Successful in 8s
All checks were successful
Docker / build (release) Successful in 8s
This commit is contained in:
parent
29d2153ceb
commit
13272cb9c7
8 changed files with 174 additions and 37 deletions
24
photo21/static/copy-button.js
Normal file
24
photo21/static/copy-button.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 }}">
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue