Compare commits

...

10 commits

30 changed files with 522 additions and 123 deletions

55
.env.example Normal file
View file

@ -0,0 +1,55 @@
# Copy this file to .env and fill in the values
# .env is gitignored and must never be committed
SECRET_KEY=change-me-to-a-long-random-string
# Set to True only for development
DEBUG=False
# Comma-separated list of additional allowed hosts (beyond 127.0.0.1 and localhost)
EXTRA_HOSTS=photos.crans.org,photos-dev.crans.org
# Comma-separated list of admins in "Name:email" format
ADMINS=admin:photos-admin@lists.crans.org
# Email address used as sender for server emails
SERVER_EMAIL=photos@crans.org
# Email verification: 'mandatory', 'optional', or 'none'
EMAIL_VERIFICATION=mandatory
# Mail server settings
SMTP_HOST=localhost
SMTP_PORT=25
#SMTP_USER=
#SMTP_PASSWORD=
SMTP_USE_TLS=False
# OAuth2 settings
# Enable OAuth2 login
OAUTH_ENABLED=False
# Disable normal username/password login (requires OAUTH_ENABLED=True)
OAUTH_ONLY=False
# OAuth2 server base URL (e.g. auth.example.com)
#OAUTH_SERVER_URL=
# OAuth2 app credentials
#OAUTH_CLIENT_ID=
#OAUTH_CLIENT_SECRET=
# Button appearance on the login page
#OAUTH_BUTTON_TEXT=Login with OAuth
#OAUTH_BUTTON_IMAGE=
# Space-separated OAuth2 scopes
#OAUTH_SCOPE=openid profile email
# Database engine: 'sqlite' or 'postgres'
DB_ENGINE=sqlite
# PostgreSQL settings (only used when DB_ENGINE=postgres)
#DB_NAME=photo21
#DB_USER=photo21
#DB_PASSWORD=
#DB_HOST=localhost
#DB_PORT=5432
# SQLite settings (only used when DB_ENGINE=sqlite)
#DB_PATH=/app/data/db.sqlite3

View file

@ -0,0 +1,44 @@
name: Docker
on:
push:
branches:
- master
tags:
- 'v*'
jobs:
build:
runs-on: docker
steps:
- uses: actions/checkout@v3
- name: Log in to Codeberg registry
if: startsWith(github.ref, 'refs/tags/')
uses: docker/login-action@v3
with:
registry: codeberg.org
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract version tag
if: startsWith(github.ref, 'refs/tags/')
id: meta
run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build (verify only, no push)
if: github.ref == 'refs/heads/master'
uses: docker/build-push-action@v5
with:
context: .
push: false
- name: Build and push (tagged release)
if: startsWith(github.ref, 'refs/tags/')
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
codeberg.org/${{ github.repository }}:${{ steps.meta.outputs.tag }}
codeberg.org/${{ github.repository }}:latest

22
Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Create volume mount points
RUN mkdir -p /app/media /app/static /app/data
# Collect static files at build time (uses a dummy key, no DB needed)
RUN SECRET_KEY=build-time-placeholder DB_ENGINE=sqlite python manage.py collectstatic --noinput
EXPOSE 8000
RUN chmod +x entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]

View file

@ -1,5 +1,7 @@
# Photo server 2021-2023
This project is a fork of [Photo21](https://gitlab.crans.org/bde/photo21/) developped at ENS Paris-Saclay.
[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0.txt)
This is the source code for the webserver hosting pictures from the
@ -15,9 +17,6 @@ run and to maintain.
```bash
sudo apt install git gettext python3-django python3-django-allauth python3-django-crispy-forms python3-docutils python3-exifread python3-pil
# Only for production
sudo apt install nginx uwsgi uwsgi-plugin-python3 python3-certbot-nginx
```
2. **Cloning.**
@ -25,25 +24,14 @@ run and to maintain.
In production, we usually use `/var/www/photos/` as the `root` user.
```bash
git clone https://gitlab.crans.org/bde/photo21.git && cd photo21
git clone https://codeberg.org/krek0/photo21.git && cd photo21
```
3. **Configuration (production only).**
```bash
# Only for production
sudo mkdir static media
sudo cp docs/maintenance.html static/maintenance.html
sudo chown www-data:www-data -R static media
sudo chmod g+rwx -R static media
sudo chmod +x maintenance_tool.sh
sudo cp docs/uwsgi_photos.ini /etc/uwsgi/apps-available/uwsgi_photos.ini
sudo ln -s /etc/uwsgi/apps-available/uwsgi_photos.ini /etc/uwsgi/apps-enabled/
sudo cp docs/nginx_photos_maintenance /etc/nginx/sites-available/photos.crans.org
sudo ln -s /etc/nginx/sites-available/photos.crans.org /etc/nginx/sites-enabled/
sudo cp docs/letsencrypt_photos.crans.org /etc/letsencrypt/conf.d/photos.crans.org
sudo cp docs/renewal-hooks_post_nginx /etc/letsencrypt/renewal-hooks/post/nginx
sudo certbot --config /etc/letsencrypt/conf.d/photos.crans.org.ini certonly
```
4. **Database (production only).**
@ -80,7 +68,6 @@ run and to maintain.
6. *Enjoy \o/*
In production, the NGINX site should now work.
In development, you can launch the development server using:
```bash

View file

@ -1,3 +1,5 @@
# This file is part of photo21
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
default_app_config = "allauth_oauth.apps.AllauthOAuthConfig"

12
allauth_oauth/apps.py Normal file
View file

@ -0,0 +1,12 @@
# This file is part of photo21
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
class AllauthOAuthConfig(AppConfig):
name = "allauth_oauth"
def ready(self):
import allauth_oauth.signals # noqa: F401

View file

@ -7,15 +7,15 @@ from allauth.socialaccount.providers.base import ProviderAccount
from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
class NoteKfetAccount(ProviderAccount):
class OAuthAccount(ProviderAccount):
def to_str(self):
return self.account.extra_data.get("username")
class NoteKfetProvider(OAuth2Provider):
id = "notekfet"
name = "Note Kfet"
account_class = NoteKfetAccount
class OAuthProvider(OAuth2Provider):
id = "oauth"
name = "OAuth"
account_class = OAuthAccount
def extract_uid(self, data):
return str(data["username"])
@ -39,4 +39,4 @@ class NoteKfetProvider(OAuth2Provider):
return ret
provider_classes = [NoteKfetProvider]
provider_classes = [OAuthProvider]

29
allauth_oauth/signals.py Normal file
View file

@ -0,0 +1,29 @@
# This file is part of photo21
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
from allauth.socialaccount.signals import pre_social_login
from django.dispatch import receiver
@receiver(pre_social_login)
def sync_user_fields(sender, request, sociallogin, **kwargs):
if not sociallogin.is_existing:
return
user = sociallogin.user
data = sociallogin.account.extra_data
changed = False
email = data.get("email")
if email and user.email != email:
user.email = email
changed = True
username = data.get("username")
if username and user.username != username:
user.username = username
changed = True
if changed:
user.save()

View file

@ -4,6 +4,6 @@
from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns
from .provider import NoteKfetProvider
from .provider import OAuthProvider
urlpatterns = default_urlpatterns(NoteKfetProvider)
urlpatterns = default_urlpatterns(OAuthProvider)

View file

@ -10,11 +10,11 @@ from allauth.socialaccount.providers.oauth2.views import (
OAuth2LoginView,
)
from .provider import NoteKfetProvider
from .provider import OAuthProvider
class NoteKfetOAuth2Adapter(OAuth2Adapter):
provider_id = NoteKfetProvider.id
class OAuthAdapter(OAuth2Adapter):
provider_id = OAuthProvider.id
def complete_login(self, request, app, token, **kwargs):
headers = {
@ -31,7 +31,7 @@ class NoteKfetOAuth2Adapter(OAuth2Adapter):
@property
def domain(self):
return self.settings.get("DOMAIN", "note.crans.org")
return self.settings.get("DOMAIN", "")
@property
def access_token_url(self):
@ -46,5 +46,5 @@ class NoteKfetOAuth2Adapter(OAuth2Adapter):
return f"https://{self.domain}/api/me/"
oauth2_login = OAuth2LoginView.adapter_view(NoteKfetOAuth2Adapter)
oauth2_callback = OAuth2CallbackView.adapter_view(NoteKfetOAuth2Adapter)
oauth2_login = OAuth2LoginView.adapter_view(OAuthAdapter)
oauth2_callback = OAuth2CallbackView.adapter_view(OAuthAdapter)

5
entrypoint.sh Normal file
View file

@ -0,0 +1,5 @@
#!/bin/sh
set -e
python manage.py migrate --noinput
exec gunicorn photo21.wsgi:application --bind 0.0.0.0:8000 --workers 3

View file

@ -13,8 +13,7 @@ class CustomSignupForm(SignupForm):
# Add description on email field
self.fields["email"].help_text = _(
"Please enter a valid email address ending with `@crans.org` or "
"`@ens-paris-saclay.fr`."
"Please enter a valid email address ending with `@ens-rennes.fr`"
)
def clean_email(self):
@ -22,10 +21,8 @@ class CustomSignupForm(SignupForm):
Check that the email address ends with a trusted domain.
"""
email = super().clean_email()
if not email.endswith("@crans.org") and not email.endswith(
"@ens-paris-saclay.fr"
):
if not email.endswith("@ens-rennes.fr"):
raise forms.ValidationError(
_("Must end with `@crans.org` or `@ens-paris-saclay.fr`.")
_("Must end with `@ens-rennes.fr`.")
)
return email

View file

@ -14,6 +14,7 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
import os
from decouple import Csv, config
from django.contrib.messages import constants as messages
from django.utils.translation import gettext_lazy as _
@ -25,17 +26,12 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "CHANGE ME"
SECRET_KEY = config("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
DEBUG = config("DEBUG", default=False, cast=bool)
ALLOWED_HOSTS = [
"127.0.0.1",
"localhost",
"photos.crans.org",
"photos-dev.crans.org",
]
ALLOWED_HOSTS = ["127.0.0.1", "localhost"] + config("EXTRA_HOSTS", default="", cast=Csv())
INTERNAL_IPS = [
"127.0.0.1",
@ -43,14 +39,16 @@ INTERNAL_IPS = [
]
# Admins receive server errors, this is useful to be notified of potential bugs
ADMINS = [
("admin", "photos-admin@lists.crans.org"),
]
# Format: "Name:email,Name2:email2"
ADMINS = [tuple(a.split(":")) for a in config("ADMINS", default="", cast=Csv()) if a]
# Use secure cookies in production
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
# Trust Caddy's forwarded proto header
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Remember HTTPS for 1 year
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
@ -58,6 +56,15 @@ SECURE_HSTS_PRELOAD = True
# Application definition
OAUTH_ENABLED = config("OAUTH_ENABLED", default=False, cast=bool)
OAUTH_ONLY = config("OAUTH_ONLY", default=False, cast=bool)
OAUTH_CLIENT_ID = config("OAUTH_CLIENT_ID", default="")
OAUTH_CLIENT_SECRET = config("OAUTH_CLIENT_SECRET", default="")
OAUTH_SERVER_URL = config("OAUTH_SERVER_URL", default="")
OAUTH_BUTTON_TEXT = config("OAUTH_BUTTON_TEXT", default="Login with OAuth")
OAUTH_BUTTON_IMAGE = config("OAUTH_BUTTON_IMAGE", default="")
OAUTH_SCOPE = config("OAUTH_SCOPE", default="openid profile email", cast=Csv(delimiter=" "))
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.admindocs",
@ -71,17 +78,20 @@ INSTALLED_APPS = [
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth_note_kfet",
"crispy_forms",
"photologue",
"photo21",
]
if OAUTH_ENABLED:
INSTALLED_APPS += ["allauth_oauth"]
if DEBUG:
INSTALLED_APPS += ["debug_toolbar",] # For debug and optimisations
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@ -126,12 +136,31 @@ WSGI_APPLICATION = "photo21.wsgi.application"
# Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
DATABASES = {
_db_engine = config("DB_ENGINE", default="sqlite").strip().lower()
if _db_engine == "postgres":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"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="5432"),
}
}
elif _db_engine == "sqlite":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.path.join(BASE_DIR, "db.sqlite3"),
"NAME": config("DB_PATH", default=os.path.join(BASE_DIR, "db.sqlite3")),
"OPTIONS": {
"timeout": 10,
},
}
}
}
else:
raise ValueError(f"Unknown DB_ENGINE '{_db_engine}'. Must be 'sqlite' or 'postgres'.")
CACHES = {
"default": {
@ -194,6 +223,18 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static/")
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/"
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "photo21.storage.CompressedManifestStorage",
},
}
WHITENOISE_MANIFEST_STRICT = False
LOCALE_PATHS = [os.path.join(BASE_DIR, "photo21/locale")]
FIXTURE_DIRS = [os.path.join(BASE_DIR, "photo21/fixtures")]
@ -204,9 +245,14 @@ if DEBUG:
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Mail will be sent from this address
SERVER_EMAIL = "photos@crans.org"
SERVER_EMAIL = config("SERVER_EMAIL", default="photos@crans.org")
DEFAULT_FROM_EMAIL = f"Serveur photos <{SERVER_EMAIL}>"
EMAIL_SUBJECT_PREFIX = "[Serveur photos] "
EMAIL_HOST = config("SMTP_HOST", default="localhost")
EMAIL_PORT = config("SMTP_PORT", default=25, cast=int)
EMAIL_HOST_USER = config("SMTP_USER", default="")
EMAIL_HOST_PASSWORD = config("SMTP_PASSWORD", default="")
EMAIL_USE_TLS = config("SMTP_USE_TLS", default=False, cast=bool)
# After login redirect user to transfer page
LOGIN_REDIRECT_URL = "/"
@ -226,16 +272,23 @@ MESSAGE_TAGS = {
# Allauth configuration ## For the django =< 5.0
ACCOUNT_EMAIL_REQUIRED = True
# ACCOUNT_SIGNUP_FIELDS = ['email*', 'username*', 'password1*', 'password2*'] ## For the django =< 5.0
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION = config("EMAIL_VERIFICATION", default="mandatory")
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
# ACCOUNT_LOGIN_METHODS = {'username', 'email'}
ACCOUNT_FORMS = {"signup": "photo21.forms.CustomSignupForm"}
SOCIALACCOUNT_PROVIDERS = {
"notekfet": {
# Fetch user profile
"SCOPE": ["1_1"],
if OAUTH_ENABLED:
SOCIALACCOUNT_ONLY = OAUTH_ONLY
SOCIALACCOUNT_PROVIDERS = {
"oauth": {
"SCOPE": OAUTH_SCOPE,
"DOMAIN": OAUTH_SERVER_URL,
"APP": {
"client_id": OAUTH_CLIENT_ID,
"secret": OAUTH_CLIENT_SECRET,
},
}
},
}
# Use Bootstrap forms
CRISPY_TEMPLATE_PACK = "bootstrap4"

View file

@ -45,6 +45,29 @@ SPDX-License-Identifier: GPL-3.0-or-later
background-color: rgba(163, 163, 163, 0.274);
}
/* Gallery - Google Photos style justified grid */
#lightgallery {
position: relative;
}
.photo-item {
position: absolute;
overflow: hidden;
visibility: hidden;
}
.photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photo-item img.photo-private {
outline: 5px solid var(--bs-danger);
outline-offset: -5px;
}
/* Language selector */
.lang-select {
border: none;

12
photo21/storage.py Normal file
View file

@ -0,0 +1,12 @@
from whitenoise.storage import CompressedManifestStaticFilesStorage
class CompressedManifestStorage(CompressedManifestStaticFilesStorage):
"""Like CompressedManifestStaticFilesStorage but silently skips missing
referenced files (e.g. source maps not included in the package)."""
def hashed_name(self, name, content=None, filename=None):
try:
return super().hashed_name(name, content, filename)
except ValueError:
return name

View file

@ -39,5 +39,5 @@ SPDX-License-Identifier: GPL-3.0-or-later
<a class="link-secondary" href="{% url 'account_reset_password' %}">{% trans "Forgot Password?" %}</a>
</div>
</div>
<p class="small text-center mt-1">{% trans "If any problem, please contact the server owners at" %} <code>photos[at]crans.org</code>.</p>
<!-- <p class="small text-center mt-1">{% trans "If any problem, please contact the server owners at" %} <code>photos[at]crans.org</code>.</p> -->
{% endblock %}

View file

@ -13,7 +13,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'">
<meta http-equiv="Referrer-Policy" content="no-referrer">
<title>{% block title %}{{ title }}{% endblock title %} - {{ request.site.name }}</title>
<meta name="description" content="{% trans "The ENS Paris-Saclay pictures server." %}">
<meta name="description" content="{% trans "The ENS Rennes pictures server." %}">
<script src="{% static "theme.js" %}"></script>
<link rel="stylesheet" href="{% static "bootstrap5/css/bootstrap.min.css" %}">
<link rel="stylesheet" href="{% static "layout.css" %}">
@ -118,16 +118,16 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% if request.user.is_authenticated %}
{% trans "Connected as" %} <code>{{ request.user.username }}</code> &middot;
{% endif %}
<a class="text-reset" href="https://gitlab.crans.org/bde/photo21/">{% trans "Source code" %}</a> &middot;
<select title="language" name="language" class="lang-select">
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% for lang_code, lang_name in LANGUAGES %}
<option value="{{ lang_code }}" {% if lang_code == LANGUAGE_CODE %}selected{% endif %}>
{{ lang_name }} ({{ lang_code }})
</option>
{% endfor %}
</select>
<a class="text-reset" href="https://github.com/krek0/photo21">{% trans "Source code" %}</a> &middot;
<!-- <select title="language" name="language" class="lang-select"> -->
<!-- {% get_current_language as LANGUAGE_CODE %} -->
<!-- {% get_available_languages as LANGUAGES %} -->
<!-- {% for lang_code, lang_name in LANGUAGES %} -->
<!-- <option value="{{ lang_code }}" {% if lang_code == LANGUAGE_CODE %}selected{% endif %}> -->
<!-- {{ lang_name }} ({{ lang_code }}) -->
<!-- </option> -->
<!-- {% endfor %} -->
<!-- </select> -->
<noscript><input type="submit"></noscript>
</p>
</form>

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<p>
{% blocktrans trimmed %}
This website aims to collect the pictures and movies taken in the student
life of ENS Paris-Saclay or involving its students.
life of ENS Rennes or involving its students.
{% endblocktrans %}
</p>
<p>
@ -25,21 +25,16 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblocktrans %}
</p>
<p>
{% blocktrans trimmed %}
If you want a photo to be deleted, please let us know:
<a href="mailto:photos@crans.org?subject=[ABUS] Nouvelle requête" class="btn btn-secondary btn-sm">Abuse request</a>
{% endblocktrans %}
</p>
{% if not perms.photologue.add_photo %}
<p>
{% blocktrans trimmed %}
If you want to obtain the right to upload pictures, please let us know:
<a href="mailto:photos@crans.org?subject=[Photographe] Demande de droits photographe" class="btn btn-secondary btn-sm">Become a photograph</a>
<a href="mailto:sinfonie@ens-rennes.fr?subject=[Photographe] Demande de droits photographe" class="btn btn-secondary btn-sm">Become a photograph</a>
{% endblocktrans %}
</p>
{% endif %}
<h3>{% trans "Last galleries" %}</h3>
<div class="row mb-2">
{% for gallery in object_list %}
@ -52,15 +47,15 @@ SPDX-License-Identifier: GPL-3.0-or-later
<h3 class="mt-4">{% trans "Behind the scene" %}</h3>
<p>
{% blocktrans trimmed %}
This project if a fork of <a href="https://gitlab.crans.org/bde/photo21/">Photo21</a>.
Because we value your privacy, we do not sell the data on this site,
unlike many free online platforms.
The dedicated server running this website is kindly hosted by the
<a href="https://www.crans.org/">Crans</a> at the ENS Paris-Saclay
basement.
It is not managed by the Crans. Current active administrators are:
{% endblocktrans %}
{% for user in superusers %} <code>{{ user.username }}</code>{% endfor %}.
{% trans "They should be contacted at" %}
<a href="mailto:photos@crans.org">photos@crans.org</a>.
<!-- The dedicated server running this website is kindly hosted by -->
<!-- <a href="https://sinfonie.org/">Sinfonie</a> at the ENS Rennes. -->
<!-- Current active administrators are: -->
<!-- {% endblocktrans %} -->
<!-- {% for user in superusers %} <code>{{ user.username }}</code>{% endfor %}. -->
<!-- {% trans "They should be contacted at" %} -->
<!-- <a href="mailto:photos@crans.org">photos@crans.org</a>. -->
</p>
{% endblock %}

View file

@ -2,21 +2,29 @@
# Copyright (C) 2021-2022 Amicale des élèves de l'ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponse
from django.http import FileResponse, Http404
from django.views.generic import ListView, View
from photologue.models import Gallery
class MediaAccess(LoginRequiredMixin, View):
class MediaAccess(View):
def get(self, request, path):
response = HttpResponse()
# Content-type will be detected by nginx
del response["Content-Type"]
response["X-Accel-Redirect"] = "/protected/media/" + path
response["Cache-Control"] = 'max-age=2678400'
if not request.user.is_authenticated and not request.session.get('public_gallery_access'):
from django.contrib.auth.views import redirect_to_login
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):
raise Http404
if not os.path.isfile(file_path):
raise Http404
response = FileResponse(open(file_path, 'rb'))
response['Cache-Control'] = 'max-age=2678400'
return response

View file

@ -3,6 +3,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.contrib import admin
from django.contrib.admin import EmptyFieldListFilter
from django.utils.translation import gettext_lazy as _
from .models import Gallery, Photo, Tag
@ -10,7 +11,7 @@ from .models import Gallery, Photo, Tag
class GalleryAdmin(admin.ModelAdmin):
list_display = ("title", "date_start", "photo_count", "get_tags")
list_filter = ["date_start", "tags"]
list_filter = ["date_start", "tags", ("public_token", EmptyFieldListFilter)]
date_hierarchy = "date_start"
prepopulated_fields = {"slug": ("title",)}
model = Gallery

View file

@ -3,8 +3,25 @@
# SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig
from django.db.backends.signals import connection_created
class PhotologueConfig(AppConfig):
default_auto_field = "django.db.models.AutoField"
name = "photologue"
def ready(self):
from django.db import connection
def enable_sqlite_wal(sender, connection, **kwargs):
if connection.vendor == "sqlite":
cursor = connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL;")
cursor.execute("PRAGMA synchronous=OFF;")
cursor.execute("PRAGMA journal_size_limit=67108864;")
cursor.execute("PRAGMA wal_autocheckpoint=1000;")
cursor.execute("PRAGMA cache_size=-65536;")
cursor.execute("PRAGMA temp_store=MEMORY;")
cursor.execute("PRAGMA mmap_size=268435456;")
connection_created.connect(enable_sqlite_wal)

View file

@ -0,0 +1,45 @@
import photologue.models
from django.db import migrations, models
def fill_dimensions(apps, schema_editor):
Photo = apps.get_model('photologue', 'Photo')
for photo in Photo.objects.filter(image_width__isnull=True).iterator():
try:
photo.image_width = photo.image.width
photo.image_height = photo.image.height
photo.save(update_fields=['image_width', 'image_height'])
except Exception:
pass
class Migration(migrations.Migration):
dependencies = [
('photologue', '0007_allow_duplicate_photo_titles'),
]
operations = [
migrations.AddField(
model_name='photo',
name='image_width',
field=models.PositiveIntegerField(editable=False, null=True),
),
migrations.AddField(
model_name='photo',
name='image_height',
field=models.PositiveIntegerField(editable=False, null=True),
),
migrations.RunPython(fill_dimensions, migrations.RunPython.noop),
migrations.AlterField(
model_name='photo',
name='image',
field=models.ImageField(
max_length=100,
upload_to=photologue.models.get_storage_path,
verbose_name='image',
width_field='image_width',
height_field='image_height',
),
),
]

View file

@ -224,8 +224,14 @@ class Gallery(models.Model):
class ImageModel(models.Model):
image = models.ImageField(
_("image"), max_length=IMAGE_FIELD_MAX_LENGTH, upload_to=get_storage_path
_("image"),
max_length=IMAGE_FIELD_MAX_LENGTH,
upload_to=get_storage_path,
width_field="image_width",
height_field="image_height",
)
image_width = models.PositiveIntegerField(editable=False, null=True)
image_height = models.PositiveIntegerField(editable=False, null=True)
date_taken = models.DateTimeField(
_("date taken"),
null=True,
@ -480,7 +486,7 @@ class ImageModel(models.Model):
self._old_image.storage.delete(
self._old_image.name
) # Delete (old) base image.
if self.date_taken is None or image_has_changed:
if (self.date_taken is None or image_has_changed) and self.image:
# Attempt to get the date the photo was taken from the EXIF data.
try:
exif_date = self.exif(self.image.file).get(

View file

@ -34,20 +34,20 @@ lgContainer.addEventListener('lgAfterOpen', () => {
const downloadUrl = this.getAttribute('href');
// Affichage de la modale stylisée
Swal.fire({
title: gettext('Download'),
text: gettext("This image is free to download, but permission from the photographer and the people in the photo is required before republishing it on another website. Furthermore, it is good practice to credit L[ENS] and the photographers in any republications."),
icon: 'info',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: gettext('Download'),
cancelButtonText: gettext('Cancel'),
background: '#1a1a1a', // Optionnel : pour matcher le thème sombre de LightGallery
color: '#fff'
}).then((result) => {
if (result.isConfirmed) {
// // Affichage de la modale stylisée
// Swal.fire({
// title: gettext('Download'),
// text: gettext("This image is free to download, but permission from the photographer and the people in the photo is required before republishing it on another website. Furthermore, it is good practice to credit L[ENS] and the photographers in any republications."),
// icon: 'info',
// showCancelButton: true,
// confirmButtonColor: '#3085d6',
// cancelButtonColor: '#d33',
// confirmButtonText: gettext('Download'),
// cancelButtonText: gettext('Cancel'),
// background: '#1a1a1a', // Optionnel : pour matcher le thème sombre de LightGallery
// color: '#fff'
// }).then((result) => {
// if (result.isConfirmed) {
// Si validé, on déclenche le téléchargement
const link = document.createElement('a');
link.href = downloadUrl;
@ -55,8 +55,8 @@ lgContainer.addEventListener('lgAfterOpen', () => {
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
});
// }
// });
}, true); // Utilisation du mode capture pour intercepter avant le script interne
}
});

View file

@ -0,0 +1,75 @@
// Justified photo grid layout (Google Photos style)
// Inspired by https://github.com/flickr/justified-layout
(function () {
const SPACING = 3;
const PADDING = 3;
function applyLayout() {
const container = document.getElementById('lightgallery');
if (!container) return;
const items = [...container.querySelectorAll('.photo-item')];
if (!items.length) return;
const containerWidth = container.offsetWidth - PADDING * 2;
const TARGET_HEIGHT = Math.min(220, Math.floor(containerWidth * 0.4));
const ratios = items.map(a => {
const w = parseInt(a.dataset.width) || 1;
const h = parseInt(a.dataset.height) || 1;
return w / h;
});
// Group items into rows
const rows = [];
let rowStart = 0;
while (rowStart < ratios.length) {
let sum = 0;
let end = rowStart;
while (end < ratios.length) {
sum += ratios[end];
const rowWidth = sum * TARGET_HEIGHT + SPACING * (end - rowStart);
end++;
if (rowWidth >= containerWidth) break;
}
rows.push({ start: rowStart, end });
rowStart = end;
}
// Position each item
let top = PADDING;
rows.forEach(({ start, end }, rowIndex) => {
const isLastRow = rowIndex === rows.length - 1;
const count = end - start;
const sumRatios = ratios.slice(start, end).reduce((a, b) => a + b, 0);
const totalSpacing = SPACING * (count - 1);
// Don't stretch the last row if it's not full
const rowHeight = isLastRow && count < 3
? TARGET_HEIGHT
: (containerWidth - totalSpacing) / sumRatios;
let left = PADDING;
for (let i = start; i < end; i++) {
const width = Math.round(ratios[i] * rowHeight);
const height = Math.round(rowHeight);
const item = items[i];
item.style.top = top + 'px';
item.style.left = left + 'px';
item.style.width = width + 'px';
item.style.height = height + 'px';
left += width + SPACING;
}
top += Math.round(rowHeight) + SPACING;
});
container.style.height = (top - SPACING + PADDING) + 'px';
items.forEach(item => {
item.style.visibility = 'visible';
const img = item.querySelector('img[data-lazy]');
if (img) img.src = img.dataset.lazy;
});
}
document.addEventListener('DOMContentLoaded', applyLayout);
window.addEventListener('resize', applyLayout);
})();

View file

@ -13,6 +13,7 @@ class lgAdmin {
this.isStaff = document.querySelector('[name=is_staff]').value === "true";
this.userId = document.querySelector('[name=user_id]').value;
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.photoId = 0;
return this;
@ -33,7 +34,8 @@ class lgAdmin {
document.getElementById("lg-delete").addEventListener('click', this.onDelete.bind(this));
// Add button to report photo
this.core.$toolbar.append(`<a href="#" id="lg-report" title="Notify abuse" class="lg-icon lg-bi-icon">${reportIcon}</a>`);
this.core.$toolbar.append(`<a href="#" id="lg-report" title="Request removal" class="lg-icon lg-bi-icon">${deleteIcon}</a>`);
document.getElementById("lg-report").style.display = 'none';
document.getElementById("lg-report").addEventListener('click', this.onReport.bind(this));
// Add button to restore a censored photo
@ -53,6 +55,7 @@ class lgAdmin {
const ownerId = el ? el.dataset.ownerId : null;
const canDelete = this.isStaff || (ownerId && ownerId === this.userId);
document.getElementById("lg-delete").style.display = canDelete ? 'block' : 'none';
document.getElementById("lg-report").style.display = canDelete ? 'none' : 'block';
const isCensored = el ? el.dataset.isPublic === 'false' : false;
document.getElementById("lg-restore").style.display = (this.canResolveCensorship && isCensored) ? 'block' : 'none';
}
@ -135,7 +138,7 @@ class lgAdmin {
// Event called when user click on report button
onReport(event) {
event.preventDefault();
if(confirm("Are you sure to report this photo?")) {
if(confirm("Are you sure to ask removal for this photo?")) {
// Build form request
const photoId = this.photoId;
const currentIndex = this.core.index;

View file

@ -20,11 +20,13 @@ 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>
<script src="{% static 'lightgallery/plugins/thumbnail/lg-thumbnail.min.js' %}"></script>
<script src="{% static 'lightgallery/plugins/zoom/lg-zoom.min.js' %}"></script>
<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>
@ -87,10 +89,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
</ul>
</div>
{% endif %}
<div class="card-body row" id="lightgallery">
<div class="card-body p-0" id="lightgallery">
{% for photo in photos %}
<a class="col-6 col-md-3 mb-2 text-center" 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' }}">
<img src="{{ photo.get_thumbnail_url }}" loading="lazy" class="img-thumbnail p-0{% if not photo.is_public %} border-danger border-5{% endif %}" alt="{{ photo.title }}{% if photo.date_taken %} - {{ photo.date_taken|date }} {{ photo.date_taken|time }}{% endif %}{% if photo.owner.get_full_name %} - {{ photo.owner.get_full_name }}{% else %} - {{ photo.owner.username }}{% endif %}{% if photo.license %} - {{ photo.license }}{% endif %}{% if not photo.is_public %} - !PRIVATE!{% endif %}">
<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 }}">
<img src="{{ photo.get_thumbnail_url }}" data-lazy="{{ photo.get_thumbnail_url }}" class="{% if not photo.is_public %}photo-private{% endif %}" alt="{{ photo.title }}{% if photo.date_taken %} - {{ photo.date_taken|date }} {{ photo.date_taken|time }}{% endif %}{% if photo.owner.get_full_name %} - {{ photo.owner.get_full_name }}{% else %} - {{ photo.owner.username }}{% endif %}{% if photo.license %} - {{ photo.license }}{% endif %}{% if not photo.is_public %} - !PRIVATE!{% endif %}">
</a>
{% endfor %}
</div>

View file

@ -237,6 +237,7 @@ class GalleryPublicView(DetailView):
if request.user.is_authenticated:
gallery = self.get_object()
return redirect("photologue:pl-gallery", slug=gallery.slug)
request.session['public_gallery_access'] = True
request.guest_mode = True
return super().get(request, *args, **kwargs)

View file

@ -5,3 +5,8 @@ django-select2>=4.8
ExifRead>=2.1.2
Pillow>=6.0.0
django-debug-toolbar>=3.2.0
python-decouple>=3.6
whitenoise>=6.0
psycopg2-binary>=2.9
requests>=2.25
gunicorn>=21.0

View file

@ -27,7 +27,7 @@ deps =
pep8-naming
pyflakes
commands =
flake8 allauth_note_kfet photo21 photologue
flake8 allauth_oauth photo21 photologue
[flake8]
ignore = W503, I100, I101