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-10 12:57:14 +02:00
parent ed28d4a9c4
commit 2a409b54f7
12 changed files with 206 additions and 48 deletions

View file

@ -41,7 +41,7 @@ OAUTH_ONLY=False
# Space-separated OAuth2 scopes # Space-separated OAuth2 scopes
#OAUTH_SCOPE=openid profile email #OAUTH_SCOPE=openid profile email
# Database engine: 'sqlite' or 'postgres' # Database engine: 'sqlite', 'postgres' or 'mariadb'
DB_ENGINE=sqlite DB_ENGINE=sqlite
# PostgreSQL settings (only used when DB_ENGINE=postgres) # PostgreSQL settings (only used when DB_ENGINE=postgres)
@ -51,5 +51,12 @@ DB_ENGINE=sqlite
#DB_HOST=localhost #DB_HOST=localhost
#DB_PORT=5432 #DB_PORT=5432
# MariaDB settings (only used when DB_ENGINE=mariadb)
#DB_NAME=photo21
#DB_USER=photo21
#DB_PASSWORD=
#DB_HOST=localhost
#DB_PORT=3306
# SQLite settings (only used when DB_ENGINE=sqlite) # SQLite settings (only used when DB_ENGINE=sqlite)
#DB_PATH=/app/data/db.sqlite3 #DB_PATH=/app/data/db.sqlite3

View file

@ -5,15 +5,16 @@ networks:
services: services:
db: db:
image: postgres:16 image: mariadb:11
container_name: photo26-db container_name: photo26-db
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: photo26 MARIADB_DATABASE: photo26
POSTGRES_USER: photo26 MARIADB_USER: photo26
POSTGRES_PASSWORD: change-me MARIADB_PASSWORD: change-me
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
volumes: volumes:
- ./postgres_data:/var/lib/postgresql/data - ./mariadb_data:/var/lib/mysql
networks: networks:
- photo26 - photo26
@ -24,12 +25,12 @@ services:
depends_on: depends_on:
- db - db
environment: environment:
DB_ENGINE: postgres DB_ENGINE: mariadb
DB_NAME: photo26 DB_NAME: photo26
DB_USER: photo26 DB_USER: photo26
DB_PASSWORD: change-me DB_PASSWORD: change-me
DB_HOST: db DB_HOST: db
DB_PORT: 5432 DB_PORT: 3306
SECRET_KEY: change-me SECRET_KEY: change-me
EXTRA_HOSTS: photos.example.org EXTRA_HOSTS: photos.example.org
volumes: volumes:

View file

@ -149,6 +149,20 @@ if _db_engine == "postgres":
"PORT": config("DB_PORT", default="5432"), "PORT": config("DB_PORT", default="5432"),
} }
} }
elif _db_engine == "mariadb":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": config("DB_NAME", default="photo21"),
"USER": config("DB_USER", default="photo21"),
"PASSWORD": config("DB_PASSWORD", default=""),
"HOST": config("DB_HOST", default="localhost"),
"PORT": config("DB_PORT", default="3306"),
"OPTIONS": {
"charset": "utf8mb4",
},
}
}
elif _db_engine == "sqlite": elif _db_engine == "sqlite":
DATABASES = { DATABASES = {
"default": { "default": {
@ -160,7 +174,7 @@ elif _db_engine == "sqlite":
} }
} }
else: else:
raise ValueError(f"Unknown DB_ENGINE '{_db_engine}'. Must be 'sqlite' or 'postgres'.") raise ValueError(f"Unknown DB_ENGINE '{_db_engine}'. Must be 'sqlite', 'postgres' or 'mariadb'.")
CACHES = { CACHES = {
"default": { "default": {

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,13 +9,35 @@ 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
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()) 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))

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

@ -13,8 +13,7 @@ class lgAdmin {
this.isStaff = document.querySelector('[name=is_staff]').value === "true"; this.isStaff = document.querySelector('[name=is_staff]').value === "true";
this.userId = document.querySelector('[name=user_id]').value; this.userId = document.querySelector('[name=user_id]').value;
this.canResolveCensorship = document.querySelector('[name=can_resolve_censorship]').value === "true"; 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.csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
this.photoId = 0; this.photoId = 0;
return this; 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="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 get(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
""" if not request.user.is_authenticated:
Download a zip file of the gallery on GET request.
"""
# Create zip file with pictures
gallery = self.get_object() 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)
gallery_year = os.path.join("/photos/", str(gallery.date_start.year)) def get(self, request, *args, **kwargs):
gallery_zip = os.path.join(gallery_year, (gallery.slug + ".zip")) gallery = self.get_object()
buffer = BytesIO()
with open(settings.MEDIA_ROOT + gallery_zip, "wb") as zip_bytes: with zipfile.ZipFile(buffer, "w") as zf:
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):