diff --git a/.gitea/workflows/test-build.yaml b/.gitea/workflows/test-build.yaml index 4fd78fd..686317f 100644 --- a/.gitea/workflows/test-build.yaml +++ b/.gitea/workflows/test-build.yaml @@ -35,9 +35,15 @@ jobs: - name: Compile translations run: uv run python manage.py compilemessages + env: + SECRET_KEY: 'super-secret-key' + ALLOWED_HOSTS: '*' - name: Run tests run: uv run pytest --cov + env: + SECRET_KEY: 'super-secret-key' + ALLOWED_HOSTS: '*' build: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index b0081f7..4d9a4b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ FROM python:3.13-alpine3.23 AS builder ENV UV_SYSTEM_PYTHON=1 + +COPY --from=ghcr.io/astral-sh/uv:0.11 /uv /usr/local/bin/uv COPY pyproject.toml . -RUN pip install uv && uv pip install -r pyproject.toml && pip uninstall -y uv +RUN uv pip install -r pyproject.toml FROM python:3.13-alpine3.23 @@ -21,8 +23,12 @@ RUN chown -R ${user}:${user} /code COPY --chown=${user}:${user} . /code +# SECRET_KEY/ALLOWED_HOSTS provisionales para la construcción de la imagen +# (build.sh carga settings, que ya no traen un valor por defecto para +# ninguna de las dos, ver config/settings/env.py). En producción no se +# heredan: solo existen en esta RUN, no quedan en la imagen final. RUN apk add --no-cache --virtual .tailwind-runtime libstdc++ libgcc \ - && su ${user} -c "sh ./scripts/build.sh" \ + && SECRET_KEY=build-only ALLOWED_HOSTS='*' su ${user} -c "sh ./scripts/build.sh" \ && apk del .tailwind-runtime USER ${user} diff --git a/README.md b/README.md index a5ae018..1f92276 100644 --- a/README.md +++ b/README.md @@ -65,16 +65,20 @@ uv sync --group dev ``` Crea un archivo `.env` en la raíz del proyecto (ver -[Variables de entorno](#variables-de-entorno) más abajo). Para desarrollo -local basta con: +[Variables de entorno](#variables-de-entorno) más abajo). `SECRET_KEY` y +`ALLOWED_HOSTS` son las únicas que no tienen un valor por defecto (a +propósito: una clave/lista de hosts insegura conocida de antemano no +protege nada), así que hacen falta incluso en desarrollo local: ```env DEBUG=True +SECRET_KEY=cualquier-cadena-larga-y-aleatoria +ALLOWED_HOSTS=* ``` -Con eso, la app arranca con SQLite, `SECRET_KEY` de desarrollo, envío de -email al backend de consola (se imprime en la terminal en vez de mandarse -de verdad) y sin credenciales adicionales. +Con eso, la app arranca con SQLite, envío de email al backend de consola +(se imprime en la terminal en vez de mandarse de verdad) y sin +credenciales adicionales. ```bash uv run python manage.py migrate @@ -164,12 +168,17 @@ commit. Todas se leen en `config/settings/env.py`; sin `.env`, o si falta alguna, se usan los valores por defecto indicados (pensados para desarrollo local). +`SECRET_KEY` y `ALLOWED_HOSTS` son la excepción: no tienen valor por +defecto, así que el arranque falla con un error claro +(`ImproperlyConfigured`) si faltan, en vez de servir tráfico con una clave +insegura conocida de antemano o con el check de `Host` desactivado de +hecho. | Variable | Por defecto | Descripción | |---|---|---| -| `SECRET_KEY` | clave insegura de desarrollo | **Cámbiala en producción.** | +| `SECRET_KEY` | *(obligatoria)* | Clave única y secreta. **Distinta en cada entorno, nunca la de otro despliegue.** | | `DEBUG` | `False` | Activa páginas de error detalladas, Django Debug Toolbar y Silk. No debe estar activo en producción. | -| `ALLOWED_HOSTS` | `*` | Lista separada por comas. Debe restringirse a los dominios reales en producción. | +| `ALLOWED_HOSTS` | *(obligatoria)* | Lista separada por comas. `*` vale para desarrollo local; restringida a los dominios reales en producción. | | `CSRF_TRUSTED_ORIGINS` | `http://localhost:8000` | Lista separada por comas. Necesario si la app se sirve detrás de un dominio/HTTPS distinto. | | `DATABASE_URL` | `sqlite:///db.sqlite3` | Formato `django-environ` (p. ej. `postgres://usuario:password@host:5432/nombre_bd`). | | `S3_ENABLED` | `False` | Si es `True`, los archivos subidos (imágenes de portada/patrón) se guardan en S3 en vez de en el filesystem local. | @@ -219,7 +228,10 @@ La imagen Docker ya ejecuta `collectstatic`, `compilemessages` y el CSS precompilado al construirse, y sirve la app con Uvicorn (variables `WSGI_HOST`/`WSGI_WORKERS` para ajustar host/nº de workers). WhiteNoise se encarga de servir los estáticos directamente desde la propia app, sin -necesidad de un servidor/proxy de estáticos aparte. +necesidad de un servidor/proxy de estáticos aparte; también sirve +`/media/` (portadas/imágenes de patrón) cuando `S3_ENABLED=False` (ver +`crochet/middleware.py`), así que tampoco hace falta nada aparte para eso +salvo que uses S3. Al hacer push a `master` se dispara la integración continua (tests) y, si pasan, se construye y publica una imagen Docker (ver diff --git a/config/settings/base.py b/config/settings/base.py index 1554edf..0e52d91 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -49,7 +49,7 @@ if DEBUG: MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', - 'whitenoise.middleware.WhiteNoiseMiddleware', + 'crochet.middleware.WhiteNoiseWithMediaMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', @@ -141,6 +141,11 @@ LANGUAGES = [ ('en', 'English'), ] +# Un año (en vez del default de Django, de sesión: se perdía al cerrar el +# navegador). La deja la vista "set_language" (ver config/urls.py) al elegir +# idioma desde el selector del navbar (ver crochet/base.html). +LANGUAGE_COOKIE_AGE = 60 * 60 * 24 * 365 + TIME_ZONE = 'UTC' USE_I18N = True diff --git a/config/settings/env.py b/config/settings/env.py index e89f495..04509e0 100644 --- a/config/settings/env.py +++ b/config/settings/env.py @@ -12,11 +12,17 @@ env_file = BASE_DIR / '.env' if os.path.exists(env_file): environ.Env.read_env(env_file) -SECRET_KEY = env.str('SECRET_KEY', 'this-is-the-default-secret-key') +SECRET_KEY = env.str('SECRET_KEY') DEBUG = env.bool('DEBUG', False) CORS_ORIGIN_WHITELIST = env.str('CORS_ORIGIN_WHITELIST', 'http://localhost:8000').split(',') CSRF_TRUSTED_ORIGINS = env.str('CSRF_TRUSTED_ORIGINS', 'http://localhost:8000').split(',') +# La cookie CSRF solo hace falta leerla desde JS en sitios que la mandan a +# mano en una cabecera (fetch/XHR); aquí no la lee ningún script (los POST +# de HTMX/fetch usan el token embebido en el HTML vía csrf_token, ver +# account_settings.html/csrf.js), así que no hay motivo para exponerla al +# DOM y sí para quitarle esa superficie a un XSS. +CSRF_COOKIE_HTTPONLY = True DATABASE_URL = env.db_url('DATABASE_URL', 'sqlite:///db.sqlite3') @@ -24,7 +30,7 @@ REDIS_HOST = env.str('REDIS_HOST', 'redis') REDIS_PORT = env.str('REDIS_PORT', 6379) PAGE_SIZE = env.int('PAGE_SIZE', 20) -ALLOWED_HOSTS = env.str('ALLOWED_HOSTS', '*').split(',') +ALLOWED_HOSTS = env.str('ALLOWED_HOSTS').split(',') CELERY_BROKER_URL = env.str('CELERY_BROKER_URL', 'redis://172.17.0.1:6379/0') diff --git a/config/urls.py b/config/urls.py index 0c7368f..3681ac0 100644 --- a/config/urls.py +++ b/config/urls.py @@ -39,6 +39,7 @@ urlpatterns = [ view=TemplateView.as_view(template_name='manifest.json', content_type='application/manifest+json'), ), path('offline/', name='offline', view=TemplateView.as_view(template_name='offline.html')), + path('i18n/', include('django.conf.urls.i18n')), ] # i18n_patterns antepone el prefijo de idioma (/es/..., /en/...) a cada URL, diff --git a/crochet/locale/en/LC_MESSAGES/django.po b/crochet/locale/en/LC_MESSAGES/django.po index 3ee3494..4f9eadf 100644 --- a/crochet/locale/en/LC_MESSAGES/django.po +++ b/crochet/locale/en/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: crochet\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-16 14:05+0000\n" +"POT-Creation-Date: 2026-09-21 12:22+0000\n" "Language: en\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -239,7 +239,7 @@ msgstr "You don't have any patterns yet." #: crochet/templates/crochet/account_settings.html:4 #: crochet/templates/crochet/account_settings.html:18 -#: crochet/templates/crochet/base.html:71 +#: crochet/templates/crochet/base.html:82 msgid "Ajustes de la cuenta" msgstr "Account settings" @@ -277,15 +277,15 @@ msgstr "This will permanently delete your account and all your patterns." msgid "Cambiar tema" msgstr "Change theme" -#: crochet/templates/crochet/base.html:58 +#: crochet/templates/crochet/base.html:62 msgid "Cambiar idioma" msgstr "Change language" -#: crochet/templates/crochet/base.html:75 +#: crochet/templates/crochet/base.html:86 msgid "Cerrar sesión" msgstr "Log out" -#: crochet/templates/crochet/base.html:81 +#: crochet/templates/crochet/base.html:92 #: crochet/templates/crochet/home.html:25 #: crochet/templates/crochet/password_reset_complete.html:18 #: crochet/templates/registration/login.html:4 diff --git a/crochet/locale/es/LC_MESSAGES/django.po b/crochet/locale/es/LC_MESSAGES/django.po index 9399c4c..05806cc 100644 --- a/crochet/locale/es/LC_MESSAGES/django.po +++ b/crochet/locale/es/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: crochet\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-16 14:05+0000\n" +"POT-Creation-Date: 2026-09-21 12:22+0000\n" "Language: es\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -229,7 +229,7 @@ msgstr "" #: crochet/templates/crochet/account_settings.html:4 #: crochet/templates/crochet/account_settings.html:18 -#: crochet/templates/crochet/base.html:71 +#: crochet/templates/crochet/base.html:82 msgid "Ajustes de la cuenta" msgstr "" @@ -265,15 +265,15 @@ msgstr "" msgid "Cambiar tema" msgstr "" -#: crochet/templates/crochet/base.html:58 +#: crochet/templates/crochet/base.html:62 msgid "Cambiar idioma" msgstr "" -#: crochet/templates/crochet/base.html:75 +#: crochet/templates/crochet/base.html:86 msgid "Cerrar sesión" msgstr "" -#: crochet/templates/crochet/base.html:81 +#: crochet/templates/crochet/base.html:92 #: crochet/templates/crochet/home.html:25 #: crochet/templates/crochet/password_reset_complete.html:18 #: crochet/templates/registration/login.html:4 diff --git a/crochet/middleware.py b/crochet/middleware.py new file mode 100644 index 0000000..07c78be --- /dev/null +++ b/crochet/middleware.py @@ -0,0 +1,19 @@ +import os + +from django.conf import settings +from whitenoise.middleware import WhiteNoiseMiddleware + + +class WhiteNoiseWithMediaMiddleware(WhiteNoiseMiddleware): + # Además de los estáticos de siempre (ver WhiteNoiseMiddleware), sirve + # también MEDIA_ROOT (portadas/imágenes de patrón) cuando no hay S3 + # configurado: sin esto, en un despliegue sin S3_ENABLED no hay nada + # que sirva /media/ (STORAGES['default'] es FileSystemStorage, que solo + # sabe guardar/leer en disco, no exponerlo por HTTP), así que las + # imágenes se guardarían pero no se verían. add_files() en __init__ (no + # en cada petición) para que WhiteNoise las indexe una sola vez, igual + # que hace con STATIC_ROOT. + def __init__(self, get_response): + super().__init__(get_response) + if not settings.S3_ENABLED and os.path.isdir(settings.MEDIA_ROOT): + self.add_files(str(settings.MEDIA_ROOT), prefix=settings.MEDIA_URL) diff --git a/crochet/templates/crochet/account_settings.html b/crochet/templates/crochet/account_settings.html index 4028f75..860c09b 100644 --- a/crochet/templates/crochet/account_settings.html +++ b/crochet/templates/crochet/account_settings.html @@ -35,7 +35,7 @@