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. + [](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.
{{ 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 %} +
{% 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.
+
+
+
+
+
+
+