diff --git a/.github/workflows/build-server.yaml b/.github/workflows/build-server.yaml new file mode 100644 index 0000000..5110c13 --- /dev/null +++ b/.github/workflows/build-server.yaml @@ -0,0 +1,47 @@ +name: Build and Push Docker Images + +on: + push: + tags: + - '*' + workflow_dispatch: + +env: + IMAGE_NAME: humitifier-server + DOCKERFILE_PATH: ./humitifier-server/Dockerfile + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push main image + uses: docker/build-push-action@v4 + with: + context: . + file: ${{ env.DOCKERFILE_PATH }} + push: true + tags: | +# ghcr.io/centrefordigitalhumanities/humitifier/${{ env.IMAGE_NAME }}:latest + ghcr.io/centrefordigitalhumanities/humitifier/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + + - name: Grype Scan + id: scan + uses: anchore/scan-action@v3 + with: + image: ghcr.io/centrefordigitalhumanities/humitifier/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + fail-build: false + + - name: upload Grype SARIF report + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: ${{ steps.scan.outputs.sarif }} diff --git a/humitifier-server/Dockerfile b/humitifier-server/Dockerfile new file mode 100644 index 0000000..55b6700 --- /dev/null +++ b/humitifier-server/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.13.0-alpine3.20 AS builder + +ENV PYTHONUNBUFFERED=1 + +RUN pip install poetry && poetry config virtualenvs.in-project true +RUN apk add postgresql-dev gcc musl-dev libffi-dev + +WORKDIR /app + +COPY pyproject.toml poetry.lock ./ +COPY src/ ./src + +RUN poetry install --without dev --with=production -vvv + +FROM python:3.13.0-alpine3.20 + +WORKDIR /app + +RUN apk add postgresql-libs + +COPY --from=builder /app . +COPY docker/gunicorn.conf.py ./ +COPY docker/entrypoint.sh ./ + +RUN .venv/bin/python src/manage.py collectstatic --noinput + +CMD ["sh", "entrypoint.sh"] diff --git a/humitifier-server/docker/entrypoint.sh b/humitifier-server/docker/entrypoint.sh new file mode 100644 index 0000000..2d263bc --- /dev/null +++ b/humitifier-server/docker/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Activate our venv +source /app/.venv/bin/activate + +# Migrate the database +python src/manage.py migrate + +# Run da server +gunicorn humitifier_server.wsgi:application -c gunicorn.conf.py "$@" \ No newline at end of file diff --git a/humitifier-server/docker/gunicorn.conf.py b/humitifier-server/docker/gunicorn.conf.py new file mode 100644 index 0000000..05c6ebd --- /dev/null +++ b/humitifier-server/docker/gunicorn.conf.py @@ -0,0 +1,7 @@ +import os + +bind = "0.0.0.0:8000" +workers = 3 +capture_output = True +# How verbose the Gunicorn error logs should be +loglevel = os.getenv("LOG_LEVEL", "WARNING") \ No newline at end of file diff --git a/humitifier-server/poetry.lock b/humitifier-server/poetry.lock index 2c465b4..cb94eb4 100644 --- a/humitifier-server/poetry.lock +++ b/humitifier-server/poetry.lock @@ -413,13 +413,13 @@ django = ">=4.2" [[package]] name = "faker" -version = "30.8.0" +version = "30.8.1" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.8" files = [ - {file = "Faker-30.8.0-py3-none-any.whl", hash = "sha256:4cd0c5ea4bc1e4c902967f6e662f5f5da69f1674d9a94f54e516d27f3c2a6a16"}, - {file = "faker-30.8.0.tar.gz", hash = "sha256:3608c7fcac2acde0eaa6da28dae97628f18f14d54eaa2a92b96ae006f1621bd7"}, + {file = "Faker-30.8.1-py3-none-any.whl", hash = "sha256:4f7f133560b9d4d2a915581f4ba86f9a6a83421b89e911f36c4c96cff58135a5"}, + {file = "faker-30.8.1.tar.gz", hash = "sha256:93e8b70813f76d05d98951154681180cb795cfbcff3eced7680d963bcc0da2a9"}, ] [package.dependencies] @@ -512,6 +512,27 @@ files = [ docs = ["Sphinx", "furo"] test = ["objgraph", "psutil"] +[[package]] +name = "gunicorn" +version = "23.0.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"}, + {file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] +tornado = ["tornado (>=0.2)"] + [[package]] name = "idna" version = "3.10" @@ -608,7 +629,7 @@ type = ["mypy (>=1.11.2)"] name = "psycopg2" version = "2.9.10" description = "psycopg2 - Python-PostgreSQL Database Adapter" -optional = true +optional = false python-versions = ">=3.8" files = [ {file = "psycopg2-2.9.10-cp310-cp310-win32.whl", hash = "sha256:5df2b672140f95adb453af93a7d669d7a7bf0a56bcd26f1502329166f4a61716"}, @@ -834,7 +855,21 @@ files = [ {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, ] +[[package]] +name = "whitenoise" +version = "6.8.2" +description = "Radically simplified static file serving for WSGI applications" +optional = false +python-versions = ">=3.9" +files = [ + {file = "whitenoise-6.8.2-py3-none-any.whl", hash = "sha256:df12dce147a043d1956d81d288c6f0044147c6d2ab9726e5772ac50fb45d2280"}, + {file = "whitenoise-6.8.2.tar.gz", hash = "sha256:486bd7267a375fa9650b136daaec156ac572971acc8bf99add90817a530dd1d4"}, +] + +[package.extras] +brotli = ["brotli"] + [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "add815003ea81e8541d45420ec291a8e9a4d946acb4013db84acb90c897971e3" +content-hash = "47c8c54a6d5ace32fb3719706a80127eaaa42914338ff460231a051701ae2655" diff --git a/humitifier-server/pyproject.toml b/humitifier-server/pyproject.toml index 922188e..1496223 100644 --- a/humitifier-server/pyproject.toml +++ b/humitifier-server/pyproject.toml @@ -14,6 +14,7 @@ django-filter = "^24.3" markdown = "^3.7" django-debug-toolbar = "^4.4.6" django-simple-menu = "^2.1.3" +whitenoise = "^6.8.2" [tool.poetry.group.dev.dependencies] @@ -21,9 +22,12 @@ black = "^24.10.0" psycopg2-binary = {version = "^2.9.10", optional = true} faker = "^30.8.0" +[tool.poetry.group.production] +optional = true [tool.poetry.group.production.dependencies] -psycopg2 = {version = "^2.9.10", optional = true} +psycopg2 = "^2.9.10" +gunicorn = "^23.0.0" [build-system] requires = ["poetry-core"] diff --git a/humitifier-server/src/humitifier_server/env.py b/humitifier-server/src/humitifier_server/env.py new file mode 100644 index 0000000..7c101be --- /dev/null +++ b/humitifier-server/src/humitifier_server/env.py @@ -0,0 +1,6 @@ +from os import environ + +get = environ.get + +def get_boolean(key: str, default) -> bool: + return get(key, default=str(default)).lower() in ("true", "1", "yes", 't') \ No newline at end of file diff --git a/humitifier-server/src/humitifier_server/settings.py b/humitifier-server/src/humitifier_server/settings.py index df1a40a..f0c2dac 100644 --- a/humitifier-server/src/humitifier_server/settings.py +++ b/humitifier-server/src/humitifier_server/settings.py @@ -11,6 +11,7 @@ """ from pathlib import Path +from . import env # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -20,17 +21,20 @@ # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-ffdjavjsp%s%b069$aai#h7odtbd#!q8uu7=hn1tv&y$gdq17_" +SECRET_KEY = env.get( + "DJANGO_SECRET_KEY", + "django-insecure-ffdjavjsp%s%b069$aai#h7odtbd#!q8uu7=hn1tv&y$gdq17_" +) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = env.get_boolean("DJANGO_DEBUG", False) ALLOWED_HOSTS = [] -INTERNAL_IPS = [ - # ... - "127.0.0.1", - # ... -] +INTERNAL_IPS = ["127.0.0.1",] + +_env_hosts = env.get("DJANGO_ALLOWED_HOSTS", default=None) +if _env_hosts: + ALLOWED_HOSTS += _env_hosts.split(",") # Application definition @@ -58,6 +62,7 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -103,15 +108,62 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", - "NAME": "postgres", - "USER": "postgres", - "PASSWORD": "postgres", - "HOST": "127.0.0.1", - "PORT": "6432", + "NAME": env.get("POSTGRES_DB", default="postgres"), + "USER": env.get("POSTGRES_USER", default="postgres"), + "PASSWORD": env.get("POSTGRES_PASSWORD", default="postgres"), + "HOST": env.get("POSTGRES_HOST", default="127.0.0.1"), + "PORT": env.get("POSTGRES_PORT", default="5432"), } } +# Security + +_https_enabled = env.get_boolean("DJANGO_HTTPS", default=False) + +X_FRAME_OPTIONS = "DENY" +SECURE_SSL_REDIRECT = _https_enabled + +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +SESSION_COOKIE_SECURE = _https_enabled +CSRF_COOKIE_SECURE = _https_enabled +# Needed to work in kubernetes, as the app may be behind a proxy/may not know it's +# own domain +SESSION_COOKIE_DOMAIN = env.get("SESSION_COOKIE_DOMAIN", default=None) +CSRF_COOKIE_DOMAIN = env.get("CSRF_COOKIE_DOMAIN", default=None) +SESSION_COOKIE_NAME = env.get("SESSION_COOKIE_NAME", + default="humitifier_sessionid") +SESSION_EXPIRE_AT_BROWSER_CLOSE = True +SESSION_COOKIE_AGE = 60 * 60 * 12 # 12 hours + +CSRF_TRUSTED_ORIGINS = [ + f"http{'s' if _https_enabled else ''}://{host}" for host in ALLOWED_HOSTS +] + +# Logging + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": env.get("LOG_LEVEL", default="INFO"), + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": env.get("DJANGO_LOG_LEVEL", default="INFO"), + "propagate": False, + }, + }, +} + # Password validation # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators @@ -152,3 +204,13 @@ # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Whitenoise + +STATIC_ROOT = BASE_DIR / "static" + +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} \ No newline at end of file