diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..930af5b --- /dev/null +++ b/.env.example @@ -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 diff --git a/.forgejo/workflows/docker.yml b/.forgejo/workflows/docker.yml new file mode 100644 index 0000000..b5e40dd --- /dev/null +++ b/.forgejo/workflows/docker.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..850dd33 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index d8e6581..5878775 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/allauth_note_kfet/__init__.py b/allauth_oauth/__init__.py similarity index 69% rename from allauth_note_kfet/__init__.py rename to allauth_oauth/__init__.py index 6c9e378..eae4948 100644 --- a/allauth_note_kfet/__init__.py +++ b/allauth_oauth/__init__.py @@ -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" diff --git a/allauth_oauth/apps.py b/allauth_oauth/apps.py new file mode 100644 index 0000000..69b727a --- /dev/null +++ b/allauth_oauth/apps.py @@ -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 diff --git a/allauth_note_kfet/provider.py b/allauth_oauth/provider.py similarity index 83% rename from allauth_note_kfet/provider.py rename to allauth_oauth/provider.py index d2ce6a5..6a6430c 100644 --- a/allauth_note_kfet/provider.py +++ b/allauth_oauth/provider.py @@ -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] diff --git a/allauth_oauth/signals.py b/allauth_oauth/signals.py new file mode 100644 index 0000000..d7f1cf6 --- /dev/null +++ b/allauth_oauth/signals.py @@ -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() diff --git a/allauth_note_kfet/urls.py b/allauth_oauth/urls.py similarity index 70% rename from allauth_note_kfet/urls.py rename to allauth_oauth/urls.py index e1bf561..190eac3 100644 --- a/allauth_note_kfet/urls.py +++ b/allauth_oauth/urls.py @@ -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) diff --git a/allauth_note_kfet/views.py b/allauth_oauth/views.py similarity index 77% rename from allauth_note_kfet/views.py rename to allauth_oauth/views.py index 86a16ee..7a568a5 100644 --- a/allauth_note_kfet/views.py +++ b/allauth_oauth/views.py @@ -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) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..37554d0 --- /dev/null +++ b/entrypoint.sh @@ -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 diff --git a/photo21/forms.py b/photo21/forms.py index 7c15e1e..253fab3 100644 --- a/photo21/forms.py +++ b/photo21/forms.py @@ -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 diff --git a/photo21/settings.py b/photo21/settings.py index 7f86536..ec542ea 100644 --- a/photo21/settings.py +++ b/photo21/settings.py @@ -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 = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), +_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": 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" diff --git a/photo21/static/layout.css b/photo21/static/layout.css index a0026d1..8e5568e 100644 --- a/photo21/static/layout.css +++ b/photo21/static/layout.css @@ -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; diff --git a/photo21/storage.py b/photo21/storage.py new file mode 100644 index 0000000..fa1b779 --- /dev/null +++ b/photo21/storage.py @@ -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 diff --git a/photo21/templates/account/login.html b/photo21/templates/account/login.html index 1e7723f..a2bc666 100644 --- a/photo21/templates/account/login.html +++ b/photo21/templates/account/login.html @@ -39,5 +39,5 @@ SPDX-License-Identifier: GPL-3.0-or-later {% trans "Forgot Password?" %} -

{% trans "If any problem, please contact the server owners at" %} photos[at]crans.org.

+ {% endblock %} diff --git a/photo21/templates/base.html b/photo21/templates/base.html index eeca7fd..602ee57 100644 --- a/photo21/templates/base.html +++ b/photo21/templates/base.html @@ -13,7 +13,7 @@ SPDX-License-Identifier: GPL-3.0-or-later {% block title %}{{ title }}{% endblock title %} - {{ request.site.name }} - + @@ -118,16 +118,16 @@ SPDX-License-Identifier: GPL-3.0-or-later {% if request.user.is_authenticated %} {% trans "Connected as" %} {{ request.user.username }} · {% endif %} - {% trans "Source code" %} · - + {% trans "Source code" %} · + + + + + + + + +

diff --git a/photo21/templates/index.html b/photo21/templates/index.html index b7fa2e9..d4db262 100644 --- a/photo21/templates/index.html +++ b/photo21/templates/index.html @@ -12,7 +12,7 @@ SPDX-License-Identifier: GPL-3.0-or-later

{% 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 %}

@@ -25,21 +25,16 @@ SPDX-License-Identifier: GPL-3.0-or-later {% endblocktrans %}

-

- {% blocktrans trimmed %} - If you want a photo to be deleted, please let us know: - Abuse request - {% endblocktrans %} -

- {% if not perms.photologue.add_photo %} + {% if not perms.photologue.add_photo %}

{% blocktrans trimmed %} If you want to obtain the right to upload pictures, please let us know: - Become a photograph + Become a photograph {% endblocktrans %}

{% endif %} +

{% trans "Last galleries" %}

{% for gallery in object_list %} @@ -52,15 +47,15 @@ SPDX-License-Identifier: GPL-3.0-or-later

{% trans "Behind the scene" %}

{% blocktrans trimmed %} + This project if a fork of Photo21. 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 - Crans at the ENS Paris-Saclay - basement. - It is not managed by the Crans. Current active administrators are: - {% endblocktrans %} - {% for user in superusers %} {{ user.username }}{% endfor %}. - {% trans "They should be contacted at" %} - photos@crans.org. + + + + + + +

{% endblock %} diff --git a/photo21/views.py b/photo21/views.py index 8245c4f..434be5b 100644 --- a/photo21/views.py +++ b/photo21/views.py @@ -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 diff --git a/photologue/admin.py b/photologue/admin.py index d6337f6..f994402 100644 --- a/photologue/admin.py +++ b/photologue/admin.py @@ -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 diff --git a/photologue/apps.py b/photologue/apps.py index a446591..10f0a9b 100644 --- a/photologue/apps.py +++ b/photologue/apps.py @@ -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) diff --git a/photologue/migrations/0008_photo_dimensions.py b/photologue/migrations/0008_photo_dimensions.py new file mode 100644 index 0000000..033f144 --- /dev/null +++ b/photologue/migrations/0008_photo_dimensions.py @@ -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', + ), + ), + ] diff --git a/photologue/models.py b/photologue/models.py index 5f97cf7..594ac2c 100644 --- a/photologue/models.py +++ b/photologue/models.py @@ -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( diff --git a/photologue/static/gallery_detail.js b/photologue/static/gallery_detail.js index b9fad3a..03c187a 100644 --- a/photologue/static/gallery_detail.js +++ b/photologue/static/gallery_detail.js @@ -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 } }); diff --git a/photologue/static/gallery_justified.js b/photologue/static/gallery_justified.js new file mode 100644 index 0000000..80dcc55 --- /dev/null +++ b/photologue/static/gallery_justified.js @@ -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); +})(); diff --git a/photologue/static/lightgallery/plugins/admin/lg-admin.js b/photologue/static/lightgallery/plugins/admin/lg-admin.js index 5b198c9..aa0214d 100644 --- a/photologue/static/lightgallery/plugins/admin/lg-admin.js +++ b/photologue/static/lightgallery/plugins/admin/lg-admin.js @@ -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(`${reportIcon}`); + this.core.$toolbar.append(`${deleteIcon}`); + 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; diff --git a/photologue/templates/photologue/gallery_detail.html b/photologue/templates/photologue/gallery_detail.html index 1744a05..c435f8d 100755 --- a/photologue/templates/photologue/gallery_detail.html +++ b/photologue/templates/photologue/gallery_detail.html @@ -20,11 +20,13 @@ SPDX-License-Identifier: GPL-3.0-or-later + + @@ -87,10 +89,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% endif %} -
+
{% for photo in photos %} - - {{ 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 %} + + {{ 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 %} {% endfor %}
diff --git a/photologue/views.py b/photologue/views.py index c44b1f8..a523fa1 100644 --- a/photologue/views.py +++ b/photologue/views.py @@ -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) diff --git a/requirements.txt b/requirements.txt index f3dfcdb..56bdc65 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tox.ini b/tox.ini index cc05d77..55bb7f6 100644 --- a/tox.ini +++ b/tox.ini @@ -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