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 # 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) [![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 This is the source code for the webserver hosting pictures from the
@ -15,9 +17,6 @@ run and to maintain.
```bash ```bash
sudo apt install git gettext python3-django python3-django-allauth python3-django-crispy-forms python3-docutils python3-exifread python3-pil 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.** 2. **Cloning.**
@ -25,25 +24,14 @@ run and to maintain.
In production, we usually use `/var/www/photos/` as the `root` user. In production, we usually use `/var/www/photos/` as the `root` user.
```bash ```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).** 3. **Configuration (production only).**
```bash ```bash
# Only for production
sudo mkdir static media 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 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).** 4. **Database (production only).**
@ -80,7 +68,6 @@ run and to maintain.
6. *Enjoy \o/* 6. *Enjoy \o/*
In production, the NGINX site should now work.
In development, you can launch the development server using: In development, you can launch the development server using:
```bash ```bash

View file

@ -1,3 +1,5 @@
# This file is part of photo21 # This file is part of photo21
# Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay # Copyright (C) 2022 Amicale des élèves de l'ENS Paris-Saclay
# SPDX-License-Identifier: GPL-3.0-or-later # 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 from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
class NoteKfetAccount(ProviderAccount): class OAuthAccount(ProviderAccount):
def to_str(self): def to_str(self):
return self.account.extra_data.get("username") return self.account.extra_data.get("username")
class NoteKfetProvider(OAuth2Provider): class OAuthProvider(OAuth2Provider):
id = "notekfet" id = "oauth"
name = "Note Kfet" name = "OAuth"
account_class = NoteKfetAccount account_class = OAuthAccount
def extract_uid(self, data): def extract_uid(self, data):
return str(data["username"]) return str(data["username"])
@ -39,4 +39,4 @@ class NoteKfetProvider(OAuth2Provider):
return ret 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 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, OAuth2LoginView,
) )
from .provider import NoteKfetProvider from .provider import OAuthProvider
class NoteKfetOAuth2Adapter(OAuth2Adapter): class OAuthAdapter(OAuth2Adapter):
provider_id = NoteKfetProvider.id provider_id = OAuthProvider.id
def complete_login(self, request, app, token, **kwargs): def complete_login(self, request, app, token, **kwargs):
headers = { headers = {
@ -31,7 +31,7 @@ class NoteKfetOAuth2Adapter(OAuth2Adapter):
@property @property
def domain(self): def domain(self):
return self.settings.get("DOMAIN", "note.crans.org") return self.settings.get("DOMAIN", "")
@property @property
def access_token_url(self): def access_token_url(self):
@ -46,5 +46,5 @@ class NoteKfetOAuth2Adapter(OAuth2Adapter):
return f"https://{self.domain}/api/me/" return f"https://{self.domain}/api/me/"
oauth2_login = OAuth2LoginView.adapter_view(NoteKfetOAuth2Adapter) oauth2_login = OAuth2LoginView.adapter_view(OAuthAdapter)
oauth2_callback = OAuth2CallbackView.adapter_view(NoteKfetOAuth2Adapter) 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 # Add description on email field
self.fields["email"].help_text = _( self.fields["email"].help_text = _(
"Please enter a valid email address ending with `@crans.org` or " "Please enter a valid email address ending with `@ens-rennes.fr`"
"`@ens-paris-saclay.fr`."
) )
def clean_email(self): def clean_email(self):
@ -22,10 +21,8 @@ class CustomSignupForm(SignupForm):
Check that the email address ends with a trusted domain. Check that the email address ends with a trusted domain.
""" """
email = super().clean_email() email = super().clean_email()
if not email.endswith("@crans.org") and not email.endswith( if not email.endswith("@ens-rennes.fr"):
"@ens-paris-saclay.fr"
):
raise forms.ValidationError( raise forms.ValidationError(
_("Must end with `@crans.org` or `@ens-paris-saclay.fr`.") _("Must end with `@ens-rennes.fr`.")
) )
return email return email

View file

@ -14,6 +14,7 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
import os import os
from decouple import Csv, config
from django.contrib.messages import constants as messages from django.contrib.messages import constants as messages
from django.utils.translation import gettext_lazy as _ 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/ # See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # 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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False DEBUG = config("DEBUG", default=False, cast=bool)
ALLOWED_HOSTS = [ ALLOWED_HOSTS = ["127.0.0.1", "localhost"] + config("EXTRA_HOSTS", default="", cast=Csv())
"127.0.0.1",
"localhost",
"photos.crans.org",
"photos-dev.crans.org",
]
INTERNAL_IPS = [ INTERNAL_IPS = [
"127.0.0.1", "127.0.0.1",
@ -43,14 +39,16 @@ INTERNAL_IPS = [
] ]
# Admins receive server errors, this is useful to be notified of potential bugs # Admins receive server errors, this is useful to be notified of potential bugs
ADMINS = [ # Format: "Name:email,Name2:email2"
("admin", "photos-admin@lists.crans.org"), ADMINS = [tuple(a.split(":")) for a in config("ADMINS", default="", cast=Csv()) if a]
]
# Use secure cookies in production # Use secure cookies in production
SESSION_COOKIE_SECURE = not DEBUG SESSION_COOKIE_SECURE = not DEBUG
CSRF_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 # Remember HTTPS for 1 year
SECURE_HSTS_SECONDS = 31536000 SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True SECURE_HSTS_INCLUDE_SUBDOMAINS = True
@ -58,6 +56,15 @@ SECURE_HSTS_PRELOAD = True
# Application definition # 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 = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.admindocs", "django.contrib.admindocs",
@ -71,17 +78,20 @@ INSTALLED_APPS = [
"allauth", "allauth",
"allauth.account", "allauth.account",
"allauth.socialaccount", "allauth.socialaccount",
"allauth_note_kfet",
"crispy_forms", "crispy_forms",
"photologue", "photologue",
"photo21", "photo21",
] ]
if OAUTH_ENABLED:
INSTALLED_APPS += ["allauth_oauth"]
if DEBUG: if DEBUG:
INSTALLED_APPS += ["debug_toolbar",] # For debug and optimisations INSTALLED_APPS += ["debug_toolbar",] # For debug and optimisations
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@ -126,12 +136,31 @@ WSGI_APPLICATION = "photo21.wsgi.application"
# Database # Database
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases # 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": { "default": {
"ENGINE": "django.db.backends.sqlite3", "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 = { CACHES = {
"default": { "default": {
@ -194,6 +223,18 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static/")
MEDIA_ROOT = os.path.join(BASE_DIR, "media") MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/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")] LOCALE_PATHS = [os.path.join(BASE_DIR, "photo21/locale")]
FIXTURE_DIRS = [os.path.join(BASE_DIR, "photo21/fixtures")] FIXTURE_DIRS = [os.path.join(BASE_DIR, "photo21/fixtures")]
@ -204,9 +245,14 @@ if DEBUG:
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Mail will be sent from this address # 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}>" DEFAULT_FROM_EMAIL = f"Serveur photos <{SERVER_EMAIL}>"
EMAIL_SUBJECT_PREFIX = "[Serveur photos] " 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 # After login redirect user to transfer page
LOGIN_REDIRECT_URL = "/" LOGIN_REDIRECT_URL = "/"
@ -226,16 +272,23 @@ MESSAGE_TAGS = {
# Allauth configuration ## For the django =< 5.0 # Allauth configuration ## For the django =< 5.0
ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_REQUIRED = True
# ACCOUNT_SIGNUP_FIELDS = ['email*', 'username*', 'password1*', 'password2*'] ## For the django =< 5.0 # 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_AUTHENTICATION_METHOD = "username_email"
# ACCOUNT_LOGIN_METHODS = {'username', 'email'} # ACCOUNT_LOGIN_METHODS = {'username', 'email'}
ACCOUNT_FORMS = {"signup": "photo21.forms.CustomSignupForm"} ACCOUNT_FORMS = {"signup": "photo21.forms.CustomSignupForm"}
SOCIALACCOUNT_PROVIDERS = {
"notekfet": { if OAUTH_ENABLED:
# Fetch user profile SOCIALACCOUNT_ONLY = OAUTH_ONLY
"SCOPE": ["1_1"], SOCIALACCOUNT_PROVIDERS = {
"oauth": {
"SCOPE": OAUTH_SCOPE,
"DOMAIN": OAUTH_SERVER_URL,
"APP": {
"client_id": OAUTH_CLIENT_ID,
"secret": OAUTH_CLIENT_SECRET,
}, },
} },
}
# Use Bootstrap forms # Use Bootstrap forms
CRISPY_TEMPLATE_PACK = "bootstrap4" 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); 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 */ /* Language selector */
.lang-select { .lang-select {
border: none; 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> <a class="link-secondary" href="{% url 'account_reset_password' %}">{% trans "Forgot Password?" %}</a>
</div> </div>
</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 %} {% 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="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"> <meta http-equiv="Referrer-Policy" content="no-referrer">
<title>{% block title %}{{ title }}{% endblock title %} - {{ request.site.name }}</title> <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> <script src="{% static "theme.js" %}"></script>
<link rel="stylesheet" href="{% static "bootstrap5/css/bootstrap.min.css" %}"> <link rel="stylesheet" href="{% static "bootstrap5/css/bootstrap.min.css" %}">
<link rel="stylesheet" href="{% static "layout.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 %} {% if request.user.is_authenticated %}
{% trans "Connected as" %} <code>{{ request.user.username }}</code> &middot; {% trans "Connected as" %} <code>{{ request.user.username }}</code> &middot;
{% endif %} {% endif %}
<a class="text-reset" href="https://gitlab.crans.org/bde/photo21/">{% trans "Source code" %}</a> &middot; <a class="text-reset" href="https://github.com/krek0/photo21">{% trans "Source code" %}</a> &middot;
<select title="language" name="language" class="lang-select"> <!-- <select title="language" name="language" class="lang-select"> -->
{% get_current_language as LANGUAGE_CODE %} <!-- {% get_current_language as LANGUAGE_CODE %} -->
{% get_available_languages as LANGUAGES %} <!-- {% get_available_languages as LANGUAGES %} -->
{% for lang_code, lang_name in LANGUAGES %} <!-- {% for lang_code, lang_name in LANGUAGES %} -->
<option value="{{ lang_code }}" {% if lang_code == LANGUAGE_CODE %}selected{% endif %}> <!-- <option value="{{ lang_code }}" {% if lang_code == LANGUAGE_CODE %}selected{% endif %}> -->
{{ lang_name }} ({{ lang_code }}) <!-- {{ lang_name }} ({{ lang_code }}) -->
</option> <!-- </option> -->
{% endfor %} <!-- {% endfor %} -->
</select> <!-- </select> -->
<noscript><input type="submit"></noscript> <noscript><input type="submit"></noscript>
</p> </p>
</form> </form>

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
This website aims to collect the pictures and movies taken in the student 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 %} {% endblocktrans %}
</p> </p>
<p> <p>
@ -25,21 +25,16 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endblocktrans %} {% endblocktrans %}
</p> </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 %} {% if not perms.photologue.add_photo %}
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
If you want to obtain the right to upload pictures, please let us know: 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 %} {% endblocktrans %}
</p> </p>
{% endif %} {% endif %}
<h3>{% trans "Last galleries" %}</h3> <h3>{% trans "Last galleries" %}</h3>
<div class="row mb-2"> <div class="row mb-2">
{% for gallery in object_list %} {% 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> <h3 class="mt-4">{% trans "Behind the scene" %}</h3>
<p> <p>
{% blocktrans trimmed %} {% 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, Because we value your privacy, we do not sell the data on this site,
unlike many free online platforms. unlike many free online platforms.
The dedicated server running this website is kindly hosted by the <!-- The dedicated server running this website is kindly hosted by -->
<a href="https://www.crans.org/">Crans</a> at the ENS Paris-Saclay <!-- <a href="https://sinfonie.org/">Sinfonie</a> at the ENS Rennes. -->
basement. <!-- Current active administrators are: -->
It is not managed by the Crans. Current active administrators are: <!-- {% endblocktrans %} -->
{% endblocktrans %} <!-- {% for user in superusers %} <code>{{ user.username }}</code>{% endfor %}. -->
{% for user in superusers %} <code>{{ user.username }}</code>{% endfor %}. <!-- {% trans "They should be contacted at" %} -->
{% trans "They should be contacted at" %} <!-- <a href="mailto:photos@crans.org">photos@crans.org</a>. -->
<a href="mailto:photos@crans.org">photos@crans.org</a>.
</p> </p>
{% endblock %} {% endblock %}

View file

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

View file

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

View file

@ -3,8 +3,25 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from django.apps import AppConfig from django.apps import AppConfig
from django.db.backends.signals import connection_created
class PhotologueConfig(AppConfig): class PhotologueConfig(AppConfig):
default_auto_field = "django.db.models.AutoField" default_auto_field = "django.db.models.AutoField"
name = "photologue" 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): class ImageModel(models.Model):
image = models.ImageField( 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 = models.DateTimeField(
_("date taken"), _("date taken"),
null=True, null=True,
@ -480,7 +486,7 @@ class ImageModel(models.Model):
self._old_image.storage.delete( self._old_image.storage.delete(
self._old_image.name self._old_image.name
) # Delete (old) base image. ) # 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. # Attempt to get the date the photo was taken from the EXIF data.
try: try:
exif_date = self.exif(self.image.file).get( exif_date = self.exif(self.image.file).get(

View file

@ -34,20 +34,20 @@ lgContainer.addEventListener('lgAfterOpen', () => {
const downloadUrl = this.getAttribute('href'); const downloadUrl = this.getAttribute('href');
// Affichage de la modale stylisée // // Affichage de la modale stylisée
Swal.fire({ // Swal.fire({
title: gettext('Download'), // 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."), // 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', // icon: 'info',
showCancelButton: true, // showCancelButton: true,
confirmButtonColor: '#3085d6', // confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33', // cancelButtonColor: '#d33',
confirmButtonText: gettext('Download'), // confirmButtonText: gettext('Download'),
cancelButtonText: gettext('Cancel'), // cancelButtonText: gettext('Cancel'),
background: '#1a1a1a', // Optionnel : pour matcher le thème sombre de LightGallery // background: '#1a1a1a', // Optionnel : pour matcher le thème sombre de LightGallery
color: '#fff' // color: '#fff'
}).then((result) => { // }).then((result) => {
if (result.isConfirmed) { // if (result.isConfirmed) {
// Si validé, on déclenche le téléchargement // Si validé, on déclenche le téléchargement
const link = document.createElement('a'); const link = document.createElement('a');
link.href = downloadUrl; link.href = downloadUrl;
@ -55,8 +55,8 @@ lgContainer.addEventListener('lgAfterOpen', () => {
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} // }
}); // });
}, true); // Utilisation du mode capture pour intercepter avant le script interne }, 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.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;
@ -33,7 +34,8 @@ class lgAdmin {
document.getElementById("lg-delete").addEventListener('click', this.onDelete.bind(this)); document.getElementById("lg-delete").addEventListener('click', this.onDelete.bind(this));
// Add button to report photo // 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)); document.getElementById("lg-report").addEventListener('click', this.onReport.bind(this));
// Add button to restore a censored photo // Add button to restore a censored photo
@ -53,6 +55,7 @@ class lgAdmin {
const ownerId = el ? el.dataset.ownerId : null; const ownerId = el ? el.dataset.ownerId : null;
const canDelete = this.isStaff || (ownerId && ownerId === this.userId); const canDelete = this.isStaff || (ownerId && ownerId === this.userId);
document.getElementById("lg-delete").style.display = canDelete ? 'block' : 'none'; 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; const isCensored = el ? el.dataset.isPublic === 'false' : false;
document.getElementById("lg-restore").style.display = (this.canResolveCensorship && isCensored) ? 'block' : 'none'; 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 // Event called when user click on report button
onReport(event) { onReport(event) {
event.preventDefault(); 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 // Build form request
const photoId = this.photoId; const photoId = this.photoId;
const currentIndex = this.core.index; 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="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>
<script src="{% static 'lightgallery/plugins/thumbnail/lg-thumbnail.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 '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 'gallery_detail.js' %}"></script>
<script src="{% static 'sweetalert.js' %}"></script> <script src="{% static 'sweetalert.js' %}"></script>
<script src="{% static 'copy-button.js' %}"></script> <script src="{% static 'copy-button.js' %}"></script>
@ -87,10 +89,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
<div class="card-body row" id="lightgallery"> <div class="card-body p-0" id="lightgallery">
{% for photo in photos %} {% 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' }}"> <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 }}" 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 %}"> <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> </a>
{% endfor %} {% endfor %}
</div> </div>

View file

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

View file

@ -5,3 +5,8 @@ django-select2>=4.8
ExifRead>=2.1.2 ExifRead>=2.1.2
Pillow>=6.0.0 Pillow>=6.0.0
django-debug-toolbar>=3.2.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 pep8-naming
pyflakes pyflakes
commands = commands =
flake8 allauth_note_kfet photo21 photologue flake8 allauth_oauth photo21 photologue
[flake8] [flake8]
ignore = W503, I100, I101 ignore = W503, I100, I101