mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-30 21:45:11 +02:00
Cover both paths off the legacy Django store end to end: the lazy seed on
first access, and the migrate endpoint replaying every S3 version. The tests
need no database — the admin JWT short-circuits document authorization, so a
fixture is an S3 object on a random uuid — and read the timeline through
yhub 0.5.0's `Accept: application/json`, which spares python a lib0 decoder.
CI grows a valkey service and starts a collaboration server alongside the
backend test job; the tests skip themselves when nothing answers on the new
COLLABORATION_API_URL setting, so `make test` without the dev stack still
passes.
Writing them turned up three things worth fixing in the server.
Backend reads now seed too. getAccessType short-circuited on the admin token
before reaching the legacy store, so a server-side read of an unmigrated
document answered with an empty one, and a create-ydoc against it would have
written a second lineage beside the content the first user access was about
to seed in.
Seeding no longer decides access; the backend's answer alone does. A legacy
object that cannot be migrated — it does not decode, or it exceeds the size
we load — opens as a new document instead of denying, since no retry can fix
it and refusing would leave the document unopenable by anyone. The cause is
logged once per attempt with the bucket, key and stack, and every later access
logs that it admitted a caller without migrating.
That made the failure classifier dangerous, so it is inverted. It was an
allowlist of retryable errors — eight socket errnos — which left every way S3
can refuse (AccessDenied on a rotated key, NoSuchBucket, a region redirect)
counting as "this object is unusable". Denying, that was survivable; opening
empty, one misscoped credential would fork every document touched during the
window. Now only a failure raised while interpreting bytes we already hold is
permanent, marked at the throw site, and everything else answers a retryable
503. Guessing wrong that way costs a retry; the other way costs the document.
The admin seed is also fenced to the org and to main, like the user path
above it. The legacy store is branchless — {docid}/file is main — and the
bookkeeping is per document, so seeding ?branch=draft would have written
main's content into an orphan room and left the real one permanently empty.
Signed-off-by: Kevin Jahns <kevin.jahns@protonmail.com>
1525 lines
52 KiB
Python
Executable File
1525 lines
52 KiB
Python
Executable File
"""
|
|
Django settings for impress project.
|
|
|
|
Generated by 'django-admin startproject' using Django 3.1.5.
|
|
|
|
For more information on this file, see
|
|
https://docs.djangoproject.com/en/3.1/topics/settings/
|
|
|
|
For the full list of settings and their values, see
|
|
https://docs.djangoproject.com/en/3.1/ref/settings/
|
|
"""
|
|
|
|
import os
|
|
import tomllib
|
|
from socket import gethostbyname, gethostname
|
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
import dj_database_url
|
|
import posthog
|
|
import sentry_sdk
|
|
from configurations import Configuration, values
|
|
from corsheaders.defaults import default_headers
|
|
from csp.constants import NONE
|
|
from lasuite.configuration.values import SecretFileValue
|
|
from lasuite.oidc_login.enums import OIDCUserEndpointFormat
|
|
from sentry_sdk.integrations.django import DjangoIntegration
|
|
from sentry_sdk.integrations.logging import ignore_logger
|
|
|
|
# pylint: disable=too-many-lines
|
|
|
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data"))
|
|
|
|
KB = 1024
|
|
MB = KB * KB
|
|
GB = MB * KB
|
|
|
|
|
|
def get_release():
|
|
"""
|
|
Get the current release of the application
|
|
"""
|
|
try:
|
|
with open(os.path.join(BASE_DIR, "pyproject.toml"), "rb") as f:
|
|
pyproject_data = tomllib.load(f)
|
|
return pyproject_data["project"]["version"]
|
|
except FileNotFoundError, KeyError:
|
|
return "NA" # Default: not available
|
|
|
|
|
|
class Base(Configuration):
|
|
"""
|
|
This is the base configuration every configuration (aka environment) should inherit from. It
|
|
is recommended to configure third-party applications by creating a configuration mixins in
|
|
./configurations and compose the Base configuration with those mixins.
|
|
|
|
It depends on an environment variable that SHOULD be defined:
|
|
|
|
* DJANGO_SECRET_KEY
|
|
|
|
You may also want to override default configuration by setting the following environment
|
|
variables:
|
|
|
|
* SENTRY_DSN
|
|
* DB_NAME
|
|
* DB_HOST
|
|
* DB_PASSWORD
|
|
* DB_USER
|
|
"""
|
|
|
|
DEBUG = False
|
|
USE_SWAGGER = False
|
|
|
|
API_VERSION = "v1.0"
|
|
|
|
# Security
|
|
ALLOWED_HOSTS = values.ListValue([])
|
|
SECRET_KEY = SecretFileValue(None)
|
|
SERVER_TO_SERVER_API_TOKENS = values.ListValue([])
|
|
|
|
# Application definition
|
|
ROOT_URLCONF = "impress.urls"
|
|
WSGI_APPLICATION = "impress.wsgi.application"
|
|
|
|
# Database
|
|
DATABASES = {
|
|
"default": dj_database_url.config()
|
|
if values.DatabaseURLValue(
|
|
None, environ_name="DATABASE_URL", environ_prefix=None
|
|
)
|
|
else {
|
|
"ENGINE": values.Value(
|
|
"django.db.backends.postgresql",
|
|
environ_name="DB_ENGINE",
|
|
environ_prefix=None,
|
|
),
|
|
"NAME": values.Value(
|
|
"impress", environ_name="DB_NAME", environ_prefix=None
|
|
),
|
|
"USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None),
|
|
"PASSWORD": SecretFileValue(
|
|
"pass", environ_name="DB_PASSWORD", environ_prefix=None
|
|
),
|
|
"HOST": values.Value(
|
|
"localhost", environ_name="DB_HOST", environ_prefix=None
|
|
),
|
|
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
|
|
# Psycopg pool can be configured in the post_setup method
|
|
}
|
|
}
|
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
|
|
|
# Search
|
|
SEARCH_INDEXER_CLASS = values.Value(
|
|
default=None,
|
|
environ_name="SEARCH_INDEXER_CLASS",
|
|
environ_prefix=None,
|
|
)
|
|
SEARCH_INDEXER_BATCH_SIZE = values.IntegerValue(
|
|
default=100_000, environ_name="SEARCH_INDEXER_BATCH_SIZE", environ_prefix=None
|
|
)
|
|
INDEXING_URL = values.Value(
|
|
default=None, environ_name="INDEXING_URL", environ_prefix=None
|
|
)
|
|
SEARCH_INDEXER_COUNTDOWN = values.IntegerValue(
|
|
default=1, environ_name="SEARCH_INDEXER_COUNTDOWN", environ_prefix=None
|
|
)
|
|
SEARCH_INDEXER_SECRET = values.Value(
|
|
default=None, environ_name="SEARCH_INDEXER_SECRET", environ_prefix=None
|
|
)
|
|
SEARCH_URL = values.Value(
|
|
default=None, environ_name="SEARCH_URL", environ_prefix=None
|
|
)
|
|
SEARCH_INDEXER_QUERY_LIMIT = values.PositiveIntegerValue(
|
|
default=50, environ_name="SEARCH_INDEXER_QUERY_LIMIT", environ_prefix=None
|
|
)
|
|
|
|
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
|
|
default="HTTP_X_ORIGINAL_URL",
|
|
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# Static files (CSS, JavaScript, Images)
|
|
STATIC_URL = "/static/"
|
|
STATIC_ROOT = os.path.join(DATA_DIR, "static")
|
|
MEDIA_URL = "/media/"
|
|
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
|
|
MEDIA_BASE_URL = values.Value(
|
|
None, environ_name="MEDIA_BASE_URL", environ_prefix=None
|
|
)
|
|
|
|
SITE_ID = 1
|
|
|
|
STORAGES = {
|
|
"default": {
|
|
"BACKEND": "storages.backends.s3.S3Storage",
|
|
},
|
|
"staticfiles": {
|
|
"BACKEND": values.Value(
|
|
"whitenoise.storage.CompressedManifestStaticFilesStorage",
|
|
environ_name="STORAGES_STATICFILES_BACKEND",
|
|
),
|
|
},
|
|
# django-silk looks up its binary cProfile (.prof) storage under this
|
|
# exact alias (see silk.models). Routing it through the S3 backend keeps
|
|
# profiling artifacts off the pod filesystem, which is read-only /
|
|
# ephemeral in Kubernetes; the `silk/` prefix isolates them in the
|
|
# bucket. Only used when SILK_ENABLED and the binary profiler are on.
|
|
"SILKY_STORAGE": {
|
|
"BACKEND": "storages.backends.s3.S3Storage",
|
|
"OPTIONS": {"location": "silk"},
|
|
},
|
|
}
|
|
|
|
# Media
|
|
AWS_S3_ENDPOINT_URL = values.Value(
|
|
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
|
|
)
|
|
AWS_S3_ACCESS_KEY_ID = SecretFileValue(
|
|
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
|
|
)
|
|
AWS_S3_SECRET_ACCESS_KEY = SecretFileValue(
|
|
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
|
|
)
|
|
AWS_S3_REGION_NAME = values.Value(
|
|
environ_name="AWS_S3_REGION_NAME", environ_prefix=None
|
|
)
|
|
AWS_STORAGE_BUCKET_NAME = values.Value(
|
|
"impress-media-storage",
|
|
environ_name="AWS_STORAGE_BUCKET_NAME",
|
|
environ_prefix=None,
|
|
)
|
|
AWS_S3_SIGNATURE_VERSION = values.Value(
|
|
"s3v4",
|
|
environ_name="AWS_S3_SIGNATURE_VERSION",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# Document images
|
|
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
|
|
10 * MB, # 10MB
|
|
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
REACTIONS_MAX_PER_COMMENT = values.IntegerValue(
|
|
15,
|
|
environ_name="REACTIONS_MAX_PER_COMMENT",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
DOCUMENT_UNSAFE_MIME_TYPES = [
|
|
# Executable Files
|
|
"application/x-msdownload",
|
|
"application/x-bat",
|
|
"application/x-dosexec",
|
|
"application/x-sh",
|
|
"application/x-ms-dos-executable",
|
|
"application/x-msi",
|
|
"application/java-archive",
|
|
"application/octet-stream",
|
|
# Dynamic Web Pages
|
|
"application/x-httpd-php",
|
|
"application/x-asp",
|
|
"application/x-aspx",
|
|
"application/jsp",
|
|
"application/xhtml+xml",
|
|
"application/x-python-code",
|
|
"application/x-perl",
|
|
"text/html",
|
|
"text/javascript",
|
|
"text/x-php",
|
|
# System Files
|
|
"application/x-msdownload",
|
|
"application/x-sys",
|
|
"application/x-drv",
|
|
"application/cpl",
|
|
"application/x-apple-diskimage",
|
|
# Script Files
|
|
"application/javascript",
|
|
"application/x-vbscript",
|
|
"application/x-powershell",
|
|
"application/x-shellscript",
|
|
# Compressed/Archive Files
|
|
"application/zip",
|
|
"application/x-tar",
|
|
"application/gzip",
|
|
"application/x-bzip2",
|
|
"application/x-7z-compressed",
|
|
"application/x-rar",
|
|
"application/x-rar-compressed",
|
|
"application/x-compress",
|
|
"application/x-lzma",
|
|
# Macros in Documents
|
|
"application/vnd.ms-word",
|
|
"application/vnd.ms-excel",
|
|
"application/vnd.ms-powerpoint",
|
|
"application/vnd.ms-word.document.macroenabled.12",
|
|
"application/vnd.ms-excel.sheet.macroenabled.12",
|
|
"application/vnd.ms-powerpoint.presentation.macroenabled.12",
|
|
# Disk Images & Virtual Disk Files
|
|
"application/x-iso9660-image",
|
|
"application/x-vmdk",
|
|
"application/x-apple-diskimage",
|
|
"application/x-dmg",
|
|
# Other Dangerous MIME Types
|
|
"application/x-ms-application",
|
|
"application/x-msdownload",
|
|
"application/x-shockwave-flash",
|
|
"application/x-silverlight-app",
|
|
"application/x-java-vm",
|
|
"application/x-bittorrent",
|
|
"application/hta",
|
|
"application/x-csh",
|
|
"application/x-ksh",
|
|
"application/x-ms-regedit",
|
|
"application/x-msdownload",
|
|
"application/xml",
|
|
]
|
|
DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED = values.BooleanValue(
|
|
True,
|
|
environ_name="DOCUMENT_ATTACHMENT_CHECK_UNSAFE_MIME_TYPES_ENABLED",
|
|
environ_prefix=None,
|
|
)
|
|
# Document versions
|
|
DOCUMENT_VERSIONS_PAGE_SIZE = 50
|
|
|
|
# Document /all endpoint
|
|
DOCUMENT_ALL_ENDPOINT_ENABLED = values.BooleanValue(
|
|
default=True,
|
|
environ_name="DOCUMENT_ALL_ENDPOINT_ENABLED",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# Internationalization
|
|
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
|
|
|
# Languages
|
|
LANGUAGE_CODE = values.Value("en-us")
|
|
# cookie & language is set from frontend
|
|
LANGUAGE_COOKIE_NAME = "docs_language"
|
|
LANGUAGE_COOKIE_PATH = "/"
|
|
|
|
DRF_NESTED_MULTIPART_PARSER = {
|
|
# output of parser is converted to querydict
|
|
# if is set to False, dict python is returned
|
|
"querydict": False,
|
|
}
|
|
|
|
# Careful! Languages should be ordered by priority, as this tuple is used to get
|
|
# fallback/default languages throughout the app.
|
|
LANGUAGES = values.SingleNestedTupleValue(
|
|
(
|
|
("en-us", "English"),
|
|
("fr-fr", "Français"),
|
|
("de-de", "Deutsch"),
|
|
("nl-nl", "Nederlands"),
|
|
("es-es", "Español"),
|
|
)
|
|
)
|
|
|
|
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
|
|
|
|
TIME_ZONE = "UTC"
|
|
USE_I18N = True
|
|
USE_TZ = True
|
|
|
|
# Templates
|
|
TEMPLATES = [
|
|
{
|
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
|
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
|
"OPTIONS": {
|
|
"context_processors": [
|
|
"django.contrib.auth.context_processors.auth",
|
|
"django.contrib.messages.context_processors.messages",
|
|
"django.template.context_processors.csrf",
|
|
"django.template.context_processors.debug",
|
|
"django.template.context_processors.i18n",
|
|
"django.template.context_processors.media",
|
|
"django.template.context_processors.request",
|
|
"django.template.context_processors.tz",
|
|
],
|
|
"loaders": [
|
|
"django.template.loaders.filesystem.Loader",
|
|
"django.template.loaders.app_directories.Loader",
|
|
],
|
|
},
|
|
},
|
|
]
|
|
|
|
MIDDLEWARE = [
|
|
"django.middleware.security.SecurityMiddleware",
|
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
"django.middleware.locale.LocaleMiddleware",
|
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
|
"corsheaders.middleware.CorsMiddleware",
|
|
"django.middleware.common.CommonMiddleware",
|
|
"django.middleware.csrf.CsrfViewMiddleware",
|
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
|
"core.middleware.ForceSessionMiddleware",
|
|
"core.middleware.SaveRawBodyMiddleware",
|
|
"django.contrib.messages.middleware.MessageMiddleware",
|
|
"dockerflow.django.middleware.DockerflowMiddleware",
|
|
"csp.middleware.CSPMiddleware",
|
|
"waffle.middleware.WaffleMiddleware",
|
|
]
|
|
|
|
AUTHENTICATION_BACKENDS = [
|
|
"django.contrib.auth.backends.ModelBackend",
|
|
"core.authentication.backends.OIDCAuthenticationBackend",
|
|
]
|
|
|
|
# Django applications from the highest priority to the lowest
|
|
INSTALLED_APPS = [
|
|
# impress
|
|
"core",
|
|
"demo",
|
|
"drf_spectacular",
|
|
# Third party apps
|
|
"corsheaders",
|
|
"django_filters",
|
|
"dockerflow.django",
|
|
"rest_framework",
|
|
"parler",
|
|
"treebeard",
|
|
"easy_thumbnails",
|
|
"waffle",
|
|
# Django
|
|
"django.contrib.admin",
|
|
"django.contrib.auth",
|
|
"django.contrib.contenttypes",
|
|
"django.contrib.postgres",
|
|
"django.contrib.sessions",
|
|
"django.contrib.sites",
|
|
"django.contrib.messages",
|
|
"django.contrib.staticfiles",
|
|
# OIDC third party
|
|
"mozilla_django_oidc",
|
|
"lasuite.malware_detection",
|
|
"lasuite.marketing",
|
|
"csp",
|
|
]
|
|
|
|
# Cache
|
|
CACHES = {
|
|
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
|
|
}
|
|
|
|
REST_FRAMEWORK = {
|
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
|
"core.authentication.backends.SessionAuthentication",
|
|
),
|
|
"DEFAULT_PARSER_CLASSES": [
|
|
"rest_framework.parsers.JSONParser",
|
|
"nested_multipart_parser.drf.DrfNestedParser",
|
|
],
|
|
"DEFAULT_RENDERER_CLASSES": [
|
|
# 🔒️ Disable BrowsableAPIRenderer which provides forms allowing a user to
|
|
# see all the data in the database (ie a serializer with a ForeignKey field
|
|
# will generate a form with a field with all possible values of the FK).
|
|
"rest_framework.renderers.JSONRenderer",
|
|
],
|
|
"EXCEPTION_HANDLER": "core.api.exception_handler",
|
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
|
"PAGE_SIZE": 20,
|
|
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
|
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
|
"DEFAULT_THROTTLE_CLASSES": [
|
|
"lasuite.drf.throttling.MonitoredScopedRateThrottle",
|
|
],
|
|
"DEFAULT_THROTTLE_RATES": {
|
|
"user_list_sustained": values.Value(
|
|
default="180/hour",
|
|
environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED",
|
|
environ_prefix=None,
|
|
),
|
|
"user_list_burst": values.Value(
|
|
default="30/minute",
|
|
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
|
|
environ_prefix=None,
|
|
),
|
|
"document": values.Value(
|
|
default="80/minute",
|
|
environ_name="API_DOCUMENT_THROTTLE_RATE",
|
|
environ_prefix=None,
|
|
),
|
|
"document_access": values.Value(
|
|
default="50/minute",
|
|
environ_name="API_DOCUMENT_ACCESS_THROTTLE_RATE",
|
|
environ_prefix=None,
|
|
),
|
|
"invitation": values.Value(
|
|
default="60/minute",
|
|
environ_name="API_INVITATION_THROTTLE_RATE",
|
|
environ_prefix=None,
|
|
),
|
|
"document_ask_for_access": values.Value(
|
|
default="30/minute",
|
|
environ_name="API_DOCUMENT_ASK_FOR_ACCESS_THROTTLE_RATE",
|
|
environ_prefix=None,
|
|
),
|
|
"config": values.Value(
|
|
default="30/minute",
|
|
environ_name="API_CONFIG_THROTTLE_RATE",
|
|
environ_prefix=None,
|
|
),
|
|
},
|
|
}
|
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
|
"core.api.throttling.sentry_monitoring_throttle_failure"
|
|
)
|
|
|
|
SPECTACULAR_SETTINGS = {
|
|
"TITLE": "Impress API",
|
|
"DESCRIPTION": "This is the impress API schema.",
|
|
"VERSION": "1.0.0",
|
|
"SERVE_INCLUDE_SCHEMA": False,
|
|
"ENABLE_DJANGO_DEPLOY_CHECK": values.BooleanValue(
|
|
default=False,
|
|
environ_name="SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK",
|
|
),
|
|
"COMPONENT_SPLIT_REQUEST": True,
|
|
# OTHER SETTINGS
|
|
"SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead
|
|
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
|
|
"REDOC_DIST": "SIDECAR",
|
|
}
|
|
|
|
TRASHBIN_CUTOFF_DAYS = values.IntegerValue(
|
|
30, environ_name="TRASHBIN_CUTOFF_DAYS", environ_prefix=None
|
|
)
|
|
|
|
# Mail
|
|
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
|
|
EMAIL_BRAND_NAME = values.Value(None)
|
|
EMAIL_HOST = values.Value(None)
|
|
EMAIL_HOST_USER = values.Value(None)
|
|
EMAIL_HOST_PASSWORD = SecretFileValue(None)
|
|
EMAIL_LOGO_IMG = values.Value(None)
|
|
EMAIL_PORT = values.PositiveIntegerValue(None)
|
|
EMAIL_URL_APP = values.Value(None)
|
|
EMAIL_USE_TLS = values.BooleanValue(False)
|
|
EMAIL_USE_SSL = values.BooleanValue(False)
|
|
EMAIL_FROM = values.Value("from@example.com")
|
|
|
|
AUTH_USER_MODEL = "core.User"
|
|
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
|
|
|
# CORS
|
|
CORS_ALLOW_CREDENTIALS = True
|
|
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
|
|
CORS_ALLOWED_ORIGINS = values.ListValue([])
|
|
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
|
|
|
|
# Sentry
|
|
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
|
|
|
|
# Collaboration
|
|
COLLABORATION_WS_URL = values.Value(
|
|
None, environ_name="COLLABORATION_WS_URL", environ_prefix=None
|
|
)
|
|
COLLABORATION_WS_INACTIVITY_TIMEOUT = values.IntegerValue(
|
|
None,
|
|
environ_name="COLLABORATION_WS_INACTIVITY_TIMEOUT",
|
|
environ_prefix=None,
|
|
)
|
|
# Base url of the collaboration server's REST api, including its route
|
|
# prefix (e.g. "http://yhub:3002/collaboration"). Server-to-server only:
|
|
# used with an admin JWT to migrate legacy documents and, later, to kick
|
|
# connections when permissions change.
|
|
COLLABORATION_API_URL = values.Value(
|
|
None, environ_name="COLLABORATION_API_URL", environ_prefix=None
|
|
)
|
|
|
|
# JWT
|
|
# RSA private key (PEM) used to sign the tokens issued by
|
|
# core.services.jwt_services.JWTService. Prefer the JWT_PRIVATE_KEY_FILE
|
|
# environment variable, a PEM does not fit well in an environment variable.
|
|
JWT_PRIVATE_KEY = SecretFileValue(
|
|
None,
|
|
environ_name="JWT_PRIVATE_KEY",
|
|
environ_prefix=None,
|
|
)
|
|
# Lifetime, in seconds, of the tokens issued by the JWT service. It is both
|
|
# the "exp" claim horizon and the cache timeout of the generated tokens.
|
|
JWT_TOKEN_LIFETIME = values.IntegerValue(
|
|
default=3600,
|
|
environ_name="JWT_TOKEN_LIFETIME",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# Frontend
|
|
FRONTEND_THEME = values.Value(
|
|
None, environ_name="FRONTEND_THEME", environ_prefix=None
|
|
)
|
|
FRONTEND_HOMEPAGE_FEATURE_ENABLED = values.BooleanValue(
|
|
default=True,
|
|
environ_name="FRONTEND_HOMEPAGE_FEATURE_ENABLED",
|
|
environ_prefix=None,
|
|
)
|
|
FRONTEND_CSS_URL = values.Value(
|
|
None, environ_name="FRONTEND_CSS_URL", environ_prefix=None
|
|
)
|
|
FRONTEND_JS_URL = values.Value(
|
|
None, environ_name="FRONTEND_JS_URL", environ_prefix=None
|
|
)
|
|
FRONTEND_SILENT_LOGIN_ENABLED = values.BooleanValue(
|
|
default=False, environ_name="FRONTEND_SILENT_LOGIN_ENABLED", environ_prefix=None
|
|
)
|
|
THEME_CUSTOMIZATION_FILE_PATH = values.Value(
|
|
os.path.join(BASE_DIR, "impress/configuration/theme/default.json"),
|
|
environ_name="THEME_CUSTOMIZATION_FILE_PATH",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
THEME_CUSTOMIZATION_CACHE_TIMEOUT = values.IntegerValue(
|
|
60 * 60 * 24,
|
|
environ_name="THEME_CUSTOMIZATION_CACHE_TIMEOUT",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# Posthog
|
|
POSTHOG_KEY = SecretFileValue(None, environ_name="POSTHOG_KEY", environ_prefix=None)
|
|
POSTHOG_HOST = values.Value(
|
|
"https://eu.i.posthog.com", environ_name="POSTHOG_HOST", environ_prefix=None
|
|
)
|
|
|
|
# Easy thumbnails
|
|
THUMBNAIL_EXTENSION = "webp"
|
|
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
|
|
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
|
|
THUMBNAIL_ALIASES = {}
|
|
|
|
# Celery
|
|
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
|
|
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
|
|
|
|
# Session
|
|
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
|
SESSION_CACHE_ALIAS = "default"
|
|
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
|
|
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
|
|
)
|
|
SESSION_COOKIE_NAME = "docs_sessionid"
|
|
|
|
# OIDC - Authorization Code Flow
|
|
OIDC_AUTHENTICATE_CLASS = values.Value(
|
|
"lasuite.oidc_login.views.OIDCAuthenticationRequestView",
|
|
environ_name="OIDC_AUTHENTICATE_CLASS",
|
|
environ_prefix=None,
|
|
)
|
|
OIDC_CALLBACK_CLASS = values.Value(
|
|
"lasuite.oidc_login.views.OIDCAuthenticationCallbackView",
|
|
environ_name="OIDC_CALLBACK_CLASS",
|
|
environ_prefix=None,
|
|
)
|
|
OIDC_CREATE_USER = values.BooleanValue(
|
|
default=True,
|
|
environ_name="OIDC_CREATE_USER",
|
|
)
|
|
OIDC_RP_SIGN_ALGO = values.Value(
|
|
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
|
)
|
|
OIDC_RP_CLIENT_ID = values.Value(
|
|
"impress", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
|
|
)
|
|
OIDC_RP_CLIENT_SECRET = SecretFileValue(
|
|
None,
|
|
environ_name="OIDC_RP_CLIENT_SECRET",
|
|
environ_prefix=None,
|
|
)
|
|
OIDC_OP_JWKS_ENDPOINT = values.Value(
|
|
environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None
|
|
)
|
|
OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value(
|
|
environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None
|
|
)
|
|
OIDC_OP_TOKEN_ENDPOINT = values.Value(
|
|
None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None
|
|
)
|
|
OIDC_OP_USER_ENDPOINT = values.Value(
|
|
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
|
)
|
|
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
|
|
OIDCUserEndpointFormat.AUTO.name,
|
|
environ_name="OIDC_OP_USER_ENDPOINT_FORMAT",
|
|
eviron_prefix=None,
|
|
choices=[e.name for e in OIDCUserEndpointFormat],
|
|
)
|
|
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
|
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
|
)
|
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
|
|
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
|
|
)
|
|
OIDC_RP_SCOPES = values.Value(
|
|
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
|
|
)
|
|
LOGIN_REDIRECT_URL = values.Value(
|
|
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
|
|
)
|
|
LOGIN_REDIRECT_URL_FAILURE = values.Value(
|
|
None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None
|
|
)
|
|
LOGOUT_REDIRECT_URL = values.Value(
|
|
None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None
|
|
)
|
|
OIDC_USE_NONCE = values.BooleanValue(
|
|
default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None
|
|
)
|
|
OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue(
|
|
default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None
|
|
)
|
|
OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue(
|
|
default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None
|
|
)
|
|
OIDC_STORE_ID_TOKEN = values.BooleanValue(
|
|
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
|
|
)
|
|
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = values.BooleanValue(
|
|
default=True,
|
|
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
|
environ_prefix=None,
|
|
)
|
|
OIDC_USE_PKCE = values.BooleanValue(
|
|
default=False, environ_name="OIDC_USE_PKCE", environ_prefix=None
|
|
)
|
|
OIDC_PKCE_CODE_CHALLENGE_METHOD = values.Value(
|
|
default="S256",
|
|
environ_name="OIDC_PKCE_CODE_CHALLENGE_METHOD",
|
|
environ_prefix=None,
|
|
)
|
|
OIDC_PKCE_CODE_VERIFIER_SIZE = values.IntegerValue(
|
|
default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None
|
|
)
|
|
OIDC_STORE_ACCESS_TOKEN = values.BooleanValue(
|
|
default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None
|
|
)
|
|
OIDC_STORE_REFRESH_TOKEN = values.BooleanValue(
|
|
default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None
|
|
)
|
|
OIDC_STORE_REFRESH_TOKEN_KEY = values.Value(
|
|
default=None,
|
|
environ_name="OIDC_STORE_REFRESH_TOKEN_KEY",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# WARNING: Enabling this setting allows multiple user accounts to share the same email
|
|
# address. This may cause security issues and is not recommended for production use when
|
|
# email is activated as fallback for identification (see previous setting).
|
|
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
|
|
default=False,
|
|
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue(
|
|
default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None
|
|
)
|
|
|
|
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
|
|
default=values.ListValue( # retrocompatibility
|
|
default=["first_name", "last_name"],
|
|
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
|
|
environ_prefix=None,
|
|
),
|
|
environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
|
|
environ_prefix=None,
|
|
)
|
|
OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
|
|
default=values.Value( # retrocompatibility
|
|
default="first_name",
|
|
environ_name="USER_OIDC_FIELD_TO_SHORTNAME",
|
|
environ_prefix=None,
|
|
),
|
|
environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# OIDC Resource Server
|
|
|
|
OIDC_RESOURCE_SERVER_ENABLED = values.BooleanValue(
|
|
default=False, environ_name="OIDC_RESOURCE_SERVER_ENABLED", environ_prefix=None
|
|
)
|
|
|
|
OIDC_RS_BACKEND_CLASS = values.Value(
|
|
"lasuite.oidc_resource_server.backend.ResourceServerBackend",
|
|
environ_name="OIDC_RS_BACKEND_CLASS",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
|
|
|
|
OIDC_VERIFY_SSL = values.BooleanValue(
|
|
default=True, environ_name="OIDC_VERIFY_SSL", environ_prefix=None
|
|
)
|
|
|
|
OIDC_TIMEOUT = values.PositiveIntegerValue(
|
|
3, environ_name="OIDC_TIMEOUT", environ_prefix=None
|
|
)
|
|
|
|
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
|
|
|
|
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
|
|
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
|
|
)
|
|
|
|
OIDC_RS_CLIENT_ID = values.Value(
|
|
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
|
|
)
|
|
|
|
OIDC_RS_CLIENT_SECRET = values.Value(
|
|
None, environ_name="OIDC_RS_CLIENT_SECRET", environ_prefix=None
|
|
)
|
|
|
|
OIDC_RS_AUDIENCE_CLAIM = values.Value(
|
|
"client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
|
|
)
|
|
|
|
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
|
|
"A256GCM", environ_name="OIDC_RS_ENCRYPTION_ENCODING", environ_prefix=None
|
|
)
|
|
|
|
OIDC_RS_ENCRYPTION_ALGO = values.Value(
|
|
"RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
|
|
)
|
|
|
|
OIDC_RS_SIGNING_ALGO = values.Value(
|
|
"ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
|
|
)
|
|
|
|
OIDC_RS_SCOPES = values.ListValue(
|
|
["openid"], environ_name="OIDC_RS_SCOPES", environ_prefix=None
|
|
)
|
|
|
|
OIDC_RS_ALLOWED_AUDIENCES = values.ListValue(
|
|
default=[],
|
|
environ_name="OIDC_RS_ALLOWED_AUDIENCES",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
OIDC_RS_PRIVATE_KEY_STR = values.Value(
|
|
default=None,
|
|
environ_name="OIDC_RS_PRIVATE_KEY_STR",
|
|
environ_prefix=None,
|
|
)
|
|
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
|
|
default="RSA",
|
|
environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# External API Configuration
|
|
# Configure available routes and actions for external_api endpoints
|
|
EXTERNAL_API = values.DictValue(
|
|
default={
|
|
"documents": {
|
|
"enabled": True,
|
|
"actions": [
|
|
"list",
|
|
"retrieve",
|
|
"create",
|
|
"children",
|
|
],
|
|
},
|
|
"document_access": {
|
|
"enabled": False,
|
|
"actions": [],
|
|
},
|
|
"document_invitation": {
|
|
"enabled": False,
|
|
"actions": [],
|
|
},
|
|
"users": {
|
|
"enabled": True,
|
|
"actions": ["get_me"],
|
|
},
|
|
},
|
|
environ_name="EXTERNAL_API",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
|
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
|
)
|
|
|
|
# AI settings
|
|
AI_ALLOW_REACH_FROM = values.Value(
|
|
choices=("public", "authenticated", "restricted"),
|
|
default="authenticated",
|
|
environ_name="AI_ALLOW_REACH_FROM",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
MISTRAL_SDK_BASE_URL = values.Value(
|
|
None, environ_name="MISTRAL_SDK_BASE_URL", environ_prefix=None
|
|
)
|
|
MISTRAL_SDK_API_KEY = SecretFileValue(
|
|
None, environ_name="MISTRAL_SDK_API_KEY", environ_prefix=None
|
|
)
|
|
|
|
OPENAI_SDK_API_KEY = SecretFileValue(
|
|
default=SecretFileValue( # retrocompatibility
|
|
None,
|
|
environ_name="AI_API_KEY",
|
|
environ_prefix=None,
|
|
),
|
|
environ_name="OPENAI_SDK_API_KEY",
|
|
environ_prefix=None,
|
|
)
|
|
OPENAI_SDK_BASE_URL = values.Value(
|
|
default=values.Value( # retrocompatibility
|
|
None, environ_name="AI_BASE_URL", environ_prefix=None
|
|
),
|
|
environ_name="OPENAI_SDK_BASE_URL",
|
|
environ_prefix=None,
|
|
)
|
|
AI_BOT = values.DictValue(
|
|
default={
|
|
"name": _("Docs AI"),
|
|
"color": "#8bc6ff",
|
|
},
|
|
environ_name="AI_BOT",
|
|
environ_prefix=None,
|
|
)
|
|
AI_DOCUMENT_RATE_THROTTLE_RATES = {
|
|
"minute": 5,
|
|
"hour": 100,
|
|
"day": 500,
|
|
}
|
|
# Master settings to enable AI features, if you set it to False,
|
|
# all AI features will be disabled even if the other settings are enabled.
|
|
AI_FEATURE_ENABLED = values.BooleanValue(
|
|
default=False, environ_name="AI_FEATURE_ENABLED", environ_prefix=None
|
|
)
|
|
# Far better UI but more flaky for the moment
|
|
# ⚠️ AGPL license, be sure to comply with the Blocknote license
|
|
# if you enable it (https://www.blocknotejs.org/)
|
|
AI_FEATURE_BLOCKNOTE_ENABLED = values.BooleanValue(
|
|
default=False, environ_name="AI_FEATURE_BLOCKNOTE_ENABLED", environ_prefix=None
|
|
)
|
|
# UI with less features but more stable
|
|
# MIT friendly license, you can enable it without worrying about the license
|
|
AI_FEATURE_LEGACY_ENABLED = values.BooleanValue(
|
|
default=True, environ_name="AI_FEATURE_LEGACY_ENABLED", environ_prefix=None
|
|
)
|
|
AI_MODEL = values.Value(None, environ_name="AI_MODEL", environ_prefix=None)
|
|
AI_VERCEL_SDK_VERSION = values.IntegerValue(
|
|
6, environ_name="AI_VERCEL_SDK_VERSION", environ_prefix=None
|
|
)
|
|
AI_USER_RATE_THROTTLE_RATES = {
|
|
"minute": 3,
|
|
"hour": 50,
|
|
"day": 200,
|
|
}
|
|
|
|
LANGFUSE_SECRET_KEY = SecretFileValue(
|
|
None, environ_name="LANGFUSE_SECRET_KEY", environ_prefix=None
|
|
)
|
|
LANGFUSE_PUBLIC_KEY = values.Value(
|
|
None, environ_name="LANGFUSE_PUBLIC_KEY", environ_prefix=None
|
|
)
|
|
LANGFUSE_BASE_URL = values.Value(
|
|
None, environ_name="LANGFUSE_BASE_URL", environ_prefix=None
|
|
)
|
|
|
|
# Y provider microservice
|
|
Y_PROVIDER_API_KEY = SecretFileValue(
|
|
environ_name="Y_PROVIDER_API_KEY",
|
|
environ_prefix=None,
|
|
)
|
|
Y_PROVIDER_API_BASE_URL = values.Value(
|
|
environ_name="Y_PROVIDER_API_BASE_URL",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# DocSpec API microservice
|
|
DOCSPEC_API_URL = values.Value(environ_name="DOCSPEC_API_URL", environ_prefix=None)
|
|
|
|
# Imported file settings
|
|
CONVERSION_UPLOAD_ENABLED = values.BooleanValue(
|
|
False, environ_name="CONVERSION_UPLOAD_ENABLED", environ_prefix=None
|
|
)
|
|
CONVERSION_FILE_MAX_SIZE = values.IntegerValue(
|
|
20 * MB,
|
|
environ_name="CONVERSION_FILE_MAX_SIZE",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
CONVERSION_FILE_EXTENSIONS_ALLOWED = values.ListValue(
|
|
default=[".docx", ".md"],
|
|
environ_name="CONVERSION_FILE_EXTENSIONS_ALLOWED",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# Conversion endpoint
|
|
CONVERSION_API_ENDPOINT = values.Value(
|
|
default="convert",
|
|
environ_name="CONVERSION_API_ENDPOINT",
|
|
environ_prefix=None,
|
|
)
|
|
CONVERSION_API_CONTENT_FIELD = values.Value(
|
|
default="content",
|
|
environ_name="CONVERSION_API_CONTENT_FIELD",
|
|
environ_prefix=None,
|
|
)
|
|
CONVERSION_API_TIMEOUT = values.Value(
|
|
default=30,
|
|
environ_name="CONVERSION_API_TIMEOUT",
|
|
environ_prefix=None,
|
|
)
|
|
CONVERSION_API_SECURE = values.Value(
|
|
default=False,
|
|
environ_name="CONVERSION_API_SECURE",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# Logging
|
|
# We want to make it easy to log to console but by default we log production
|
|
# to Sentry and don't want to log to console.
|
|
LOGGING = {
|
|
"version": 1,
|
|
"disable_existing_loggers": False,
|
|
"formatters": {
|
|
"simple": {
|
|
"format": "{asctime} {name} {levelname} {message}",
|
|
"style": "{",
|
|
},
|
|
},
|
|
"handlers": {
|
|
"console": {
|
|
"class": "logging.StreamHandler",
|
|
"formatter": "simple",
|
|
},
|
|
},
|
|
# Override root logger to send it to console
|
|
"root": {
|
|
"handlers": ["console"],
|
|
"level": values.Value(
|
|
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
|
|
),
|
|
},
|
|
"loggers": {
|
|
"core": {
|
|
"handlers": ["console"],
|
|
"level": values.Value(
|
|
"INFO",
|
|
environ_name="LOGGING_LEVEL_LOGGERS_APP",
|
|
environ_prefix=None,
|
|
),
|
|
"propagate": True,
|
|
},
|
|
"docs.security": {
|
|
"handlers": ["console"],
|
|
"level": values.Value(
|
|
"INFO",
|
|
environ_name="LOGGING_LEVEL_LOGGERS_SECURITY",
|
|
environ_prefix=None,
|
|
),
|
|
"propagate": True,
|
|
},
|
|
},
|
|
}
|
|
|
|
MALWARE_DETECTION = {
|
|
"BACKEND": values.Value(
|
|
"lasuite.malware_detection.backends.dummy.DummyBackend",
|
|
environ_name="MALWARE_DETECTION_BACKEND",
|
|
environ_prefix=None,
|
|
),
|
|
"PARAMETERS": values.DictValue(
|
|
default={
|
|
"callback_path": "core.malware_detection.malware_detection_callback",
|
|
},
|
|
environ_name="MALWARE_DETECTION_PARAMETERS",
|
|
environ_prefix=None,
|
|
),
|
|
}
|
|
|
|
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
|
|
default=5,
|
|
environ_name="API_USERS_LIST_LIMIT",
|
|
environ_prefix=None,
|
|
)
|
|
API_USERS_SEARCH_QUERY_MIN_LENGTH = values.PositiveIntegerValue(
|
|
default=3,
|
|
environ_name="API_USERS_SEARCH_QUERY_MIN_LENGTH",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# Content Security Policy
|
|
# See https://content-security-policy.com/ for more information.
|
|
CONTENT_SECURITY_POLICY = {
|
|
"EXCLUDE_URL_PREFIXES": values.ListValue(
|
|
["/admin"],
|
|
environ_name="CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES",
|
|
environ_prefix=None,
|
|
),
|
|
"DIRECTIVES": values.DictValue(
|
|
default={
|
|
"default-src": [NONE],
|
|
"script-src": [NONE],
|
|
"style-src": [NONE],
|
|
"img-src": [NONE],
|
|
"connect-src": [NONE],
|
|
"font-src": [NONE],
|
|
"object-src": [NONE],
|
|
"media-src": [NONE],
|
|
"frame-src": [NONE],
|
|
"child-src": [NONE],
|
|
"form-action": [NONE],
|
|
"frame-ancestors": [NONE],
|
|
"base-uri": [NONE],
|
|
"worker-src": [NONE],
|
|
"manifest-src": [NONE],
|
|
"prefetch-src": [NONE],
|
|
},
|
|
environ_name="CONTENT_SECURITY_POLICY_DIRECTIVES",
|
|
environ_prefix=None,
|
|
),
|
|
}
|
|
|
|
# User accounts management
|
|
USER_RECONCILIATION_FORM_URL = values.Value(
|
|
None, environ_name="USER_RECONCILIATION_FORM_URL", environ_prefix=None
|
|
)
|
|
USER_ONBOARDING_DOCUMENTS = values.ListValue(
|
|
[], environ_name="USER_ONBOARDING_DOCUMENTS", environ_prefix=None
|
|
)
|
|
USER_ONBOARDING_SANDBOX_DOCUMENT = values.Value(
|
|
None, environ_name="USER_ONBOARDING_SANDBOX_DOCUMENT", environ_prefix=None
|
|
)
|
|
# Marketing and communication settings
|
|
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
|
False,
|
|
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
|
|
environ_prefix=None,
|
|
help_text=(
|
|
"When enabled, new users are automatically added to mailing list "
|
|
"for product updates, marketing communications, and customized emails. "
|
|
),
|
|
)
|
|
|
|
LASUITE_MARKETING = {
|
|
"BACKEND": values.Value(
|
|
"lasuite.marketing.backends.dummy.DummyBackend",
|
|
environ_name="LASUITE_MARKETING_BACKEND",
|
|
environ_prefix=None,
|
|
),
|
|
"PARAMETERS": values.DictValue(
|
|
default={},
|
|
environ_name="LASUITE_MARKETING_PARAMETERS",
|
|
environ_prefix=None,
|
|
),
|
|
}
|
|
|
|
CONTENT_METADATA_CACHE_TIMEOUT = values.IntegerValue(
|
|
60 * 60 * 24, environ_name="CONTENT_METADATA_CACHE_TIMEOUT", environ_prefix=None
|
|
)
|
|
|
|
TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS = values.IntegerValue(
|
|
10,
|
|
environ_name="TREEBEARD_PATH_COMPUTE_RETRY_MAX_ATTEMPTS",
|
|
environ_prefix=None,
|
|
)
|
|
|
|
# -- Profiling (django-silk) ---------------------------------------------
|
|
# Opt-in request/SQL/cProfile profiler, OFF by default. Turn it on in a
|
|
# given environment with SILK_ENABLED=1 (typically a throwaway staging pod
|
|
# loaded via `generate_volumetry`, or local dev) to record, per request,
|
|
# the SQL it ran with timing + originating stack, and an optional cProfile
|
|
# you can download as a binary `.prof`. When enabled, `silk` is appended to
|
|
# INSTALLED_APPS, `SilkyMiddleware` is wired near the top of MIDDLEWARE, and
|
|
# the UI is served at /silk/ (see impress/urls.py and post_setup below).
|
|
#
|
|
# NEVER enable against production with real users: silk persists request
|
|
# metadata to the database. Request/response BODIES are deliberately never
|
|
# stored (the two MAX_*_BODY_SIZE = 0 below) so document content, titles and
|
|
# emails cannot leak into the silk tables — only method, path, headers-free
|
|
# metadata, SQL and timings are kept.
|
|
SILK_ENABLED = values.BooleanValue(
|
|
False, environ_name="SILK_ENABLED", environ_prefix=None
|
|
)
|
|
# Per-request cProfile. Binary output lets you download a `.prof` and open
|
|
# it in snakeviz / pstats / tuna offline for a full call graph. The binary
|
|
# is written through the SILKY_STORAGE backend (S3, see STORAGES above), not
|
|
# the local filesystem, so it works on read-only/ephemeral pods.
|
|
SILKY_PYTHON_PROFILER = values.BooleanValue(
|
|
True, environ_name="SILK_PYTHON_PROFILER", environ_prefix=None
|
|
)
|
|
SILKY_PYTHON_PROFILER_BINARY = values.BooleanValue(
|
|
True, environ_name="SILK_PYTHON_PROFILER_BINARY", environ_prefix=None
|
|
)
|
|
# Under load, record only a sample of requests to bound silk's own overhead
|
|
# and storage (100 = every request; drop it for a thundering-herd repro).
|
|
SILKY_INTERCEPT_PERCENT = values.IntegerValue(
|
|
100, environ_name="SILK_INTERCEPT_PERCENT", environ_prefix=None
|
|
)
|
|
# Ring-buffer the stored requests so a long load run cannot fill the disk.
|
|
SILKY_MAX_RECORDED_REQUESTS = values.IntegerValue(
|
|
10000, environ_name="SILK_MAX_RECORDED_REQUESTS", environ_prefix=None
|
|
)
|
|
SILKY_MAX_RECORDED_REQUESTS_CHECK_PERCENT = 10
|
|
# Record silk's own per-request overhead so you can subtract it.
|
|
SILKY_META = True
|
|
# Lock the /silk/ UI behind an authenticated staff session.
|
|
SILKY_AUTHENTICATION = True
|
|
SILKY_AUTHORISATION = True
|
|
# RGPD: never persist request/response bodies (0 bytes kept).
|
|
SILKY_MAX_REQUEST_BODY_SIZE = 0
|
|
SILKY_MAX_RESPONSE_BODY_SIZE = 0
|
|
|
|
# pylint: disable=invalid-name
|
|
@property
|
|
def ENVIRONMENT(self):
|
|
"""Environment in which the application is launched."""
|
|
return self.__class__.__name__.lower()
|
|
|
|
# pylint: disable=invalid-name
|
|
@property
|
|
def RELEASE(self):
|
|
"""
|
|
Return the release information.
|
|
|
|
Delegate to the module function to enable easier testing.
|
|
"""
|
|
return get_release()
|
|
|
|
# pylint: disable=invalid-name
|
|
@property
|
|
def PARLER_LANGUAGES(self):
|
|
"""
|
|
Return languages for Parler computed from the LANGUAGES and LANGUAGE_CODE settings.
|
|
"""
|
|
return {
|
|
self.SITE_ID: tuple({"code": code} for code, _name in self.LANGUAGES),
|
|
"default": {
|
|
"fallbacks": [self.LANGUAGE_CODE],
|
|
"hide_untranslated": False,
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def post_setup(cls):
|
|
"""Post setup configuration.
|
|
This is the place where you can configure settings that require other
|
|
settings to be loaded.
|
|
"""
|
|
super().post_setup()
|
|
|
|
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
|
if cls.SENTRY_DSN is not None:
|
|
sentry_sdk.init(
|
|
dsn=cls.SENTRY_DSN,
|
|
environment=cls.__name__.lower(),
|
|
release=get_release(),
|
|
integrations=[DjangoIntegration()],
|
|
)
|
|
sentry_sdk.set_tag("application", "backend")
|
|
|
|
# Ignore the logs added by the DockerflowMiddleware
|
|
ignore_logger("request.summary")
|
|
|
|
if (
|
|
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
|
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
|
|
):
|
|
raise ValueError(
|
|
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
|
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
|
)
|
|
|
|
psycopg_pool_enabled = values.BooleanValue(
|
|
False, environ_name="DB_PSYCOPG_POOL_ENABLED", environ_prefix=""
|
|
)
|
|
|
|
if psycopg_pool_enabled:
|
|
cls.DATABASES["default"].update(
|
|
{
|
|
"OPTIONS": {
|
|
# https://www.psycopg.org/psycopg3/docs/api/pool.html#psycopg_pool.ConnectionPool
|
|
"pool": {
|
|
"min_size": values.IntegerValue(
|
|
4,
|
|
environ_name="DB_PSYCOPG_POOL_MIN_SIZE",
|
|
environ_prefix=None,
|
|
),
|
|
"max_size": values.IntegerValue(
|
|
None,
|
|
environ_name="DB_PSYCOPG_POOL_MAX_SIZE",
|
|
environ_prefix=None,
|
|
),
|
|
"timeout": values.IntegerValue(
|
|
3,
|
|
environ_name="DB_PSYCOPG_POOL_TIMEOUT",
|
|
environ_prefix=None,
|
|
),
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
if cls.OPENAI_SDK_API_KEY and cls.MISTRAL_SDK_API_KEY:
|
|
raise ValueError(
|
|
"Both OPENAI_SDK and MISTRAL_SDK parameters can not be set simultaneously."
|
|
)
|
|
|
|
if cls.POSTHOG_KEY is not None:
|
|
posthog.api_key = cls.POSTHOG_KEY
|
|
posthog.host = cls.POSTHOG_HOST
|
|
|
|
if cls.SILK_ENABLED:
|
|
# Activate django-silk only when explicitly turned on for this
|
|
# environment. Appending here (rather than in INSTALLED_APPS) keeps
|
|
# silk absent from every environment that does not opt in, including
|
|
# production. Guards make re-entry (post_setup can run per subclass)
|
|
# idempotent.
|
|
if "silk" not in cls.INSTALLED_APPS:
|
|
cls.INSTALLED_APPS.append("silk")
|
|
# SilkyMiddleware must be high enough to time the whole request but
|
|
# after AuthenticationMiddleware so it can attribute request.user;
|
|
# process_response runs inner-to-outer, so index 1 (just after
|
|
# SecurityMiddleware) satisfies both.
|
|
if "silk.middleware.SilkyMiddleware" not in cls.MIDDLEWARE:
|
|
cls.MIDDLEWARE.insert(1, "silk.middleware.SilkyMiddleware")
|
|
|
|
|
|
class Build(Base):
|
|
"""Settings used when the application is built.
|
|
|
|
This environment should not be used to run the application. Just to build it with non-blocking
|
|
settings.
|
|
"""
|
|
|
|
SECRET_KEY = values.Value("DummyKey")
|
|
STORAGES = {
|
|
"default": {
|
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
|
},
|
|
"staticfiles": {
|
|
"BACKEND": values.Value(
|
|
"whitenoise.storage.CompressedManifestStaticFilesStorage",
|
|
environ_name="STORAGES_STATICFILES_BACKEND",
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
class Development(Base):
|
|
"""
|
|
Development environment settings
|
|
|
|
We set DEBUG to True and configure the server to respond from all hosts.
|
|
"""
|
|
|
|
ALLOWED_HOSTS = ["*"]
|
|
CORS_ALLOW_ALL_ORIGINS = True
|
|
CSRF_TRUSTED_ORIGINS = values.ListValue(
|
|
["http://localhost:8072", "http://localhost:3000"],
|
|
environ_name="DJANGO_CSRF_TRUSTED_ORIGINS",
|
|
environ_prefix=None,
|
|
)
|
|
CORS_ALLOW_HEADERS = (
|
|
*default_headers,
|
|
"if-none-match",
|
|
"if-modified-since",
|
|
)
|
|
CORS_EXPOSE_HEADERS = ["ETag"]
|
|
DEBUG = True
|
|
|
|
USE_SWAGGER = True
|
|
CACHES = {
|
|
"default": {
|
|
"BACKEND": "django_redis.cache.RedisCache",
|
|
"LOCATION": values.Value(
|
|
"redis://redis:6379/2",
|
|
environ_name="REDIS_URL",
|
|
environ_prefix=None,
|
|
),
|
|
"TIMEOUT": values.IntegerValue(
|
|
30, # timeout in seconds
|
|
environ_name="CACHES_DEFAULT_TIMEOUT",
|
|
environ_prefix=None,
|
|
),
|
|
"OPTIONS": {
|
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
|
},
|
|
},
|
|
}
|
|
|
|
def __init__(self):
|
|
# pylint: disable=invalid-name
|
|
self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"]
|
|
self.CONTENT_SECURITY_POLICY["EXCLUDE_URL_PREFIXES"] += [
|
|
f"/api/{self.API_VERSION}/swagger",
|
|
f"/api/{self.API_VERSION}/redoc",
|
|
"/silk",
|
|
]
|
|
|
|
|
|
class Test(Base):
|
|
"""Test environment settings"""
|
|
|
|
PASSWORD_HASHERS = [
|
|
"django.contrib.auth.hashers.MD5PasswordHasher",
|
|
]
|
|
USE_SWAGGER = True
|
|
# Static files are not used in the test environment
|
|
# Tests are raising warnings because the /data/static directory does not exist
|
|
STATIC_ROOT = None
|
|
|
|
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
|
|
|
def __init__(self):
|
|
# pylint: disable=invalid-name
|
|
self.INSTALLED_APPS += ["drf_spectacular_sidecar"]
|
|
|
|
|
|
class ContinuousIntegration(Test):
|
|
"""
|
|
Continuous Integration environment settings
|
|
|
|
nota bene: it should inherit from the Test environment.
|
|
"""
|
|
|
|
|
|
class Production(Base):
|
|
"""
|
|
Production environment settings
|
|
|
|
You must define the ALLOWED_HOSTS environment variable in Production
|
|
configuration (and derived configurations):
|
|
ALLOWED_HOSTS=["foo.com", "foo.fr"]
|
|
"""
|
|
|
|
# Security
|
|
# Add allowed host from environment variables.
|
|
# The machine hostname is added by default,
|
|
# it makes the application pingable by a load balancer on the same machine by example
|
|
ALLOWED_HOSTS = [
|
|
*values.ListValue([], environ_name="ALLOWED_HOSTS"),
|
|
gethostbyname(gethostname()),
|
|
]
|
|
CSRF_TRUSTED_ORIGINS = values.ListValue([])
|
|
SECURE_BROWSER_XSS_FILTER = True
|
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
|
|
|
# SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest
|
|
# object when your application is behind a reverse proxy.
|
|
#
|
|
# Keep this SECURE_PROXY_SSL_HEADER configuration only if :
|
|
# - your Django app is behind a proxy.
|
|
# - your proxy strips the X-Forwarded-Proto header from all incoming requests
|
|
# - Your proxy sets the X-Forwarded-Proto header and sends it to Django
|
|
#
|
|
# In other cases, you should comment the following line to avoid security issues.
|
|
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
SECURE_HSTS_SECONDS = 60
|
|
SECURE_HSTS_PRELOAD = True
|
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
|
SECURE_SSL_REDIRECT = True
|
|
SECURE_REDIRECT_EXEMPT = [
|
|
"^__lbheartbeat__",
|
|
"^__heartbeat__",
|
|
]
|
|
|
|
# Modern browsers require to have the `secure` attribute on cookies with `Samesite=none`
|
|
CSRF_COOKIE_SECURE = True
|
|
SESSION_COOKIE_SECURE = True
|
|
SESSION_CACHE_ALIAS = "session"
|
|
|
|
# Privacy
|
|
SECURE_REFERRER_POLICY = "same-origin"
|
|
|
|
# Conversion API: Always verify SSL in production
|
|
CONVERSION_API_SECURE = True
|
|
|
|
# Cache
|
|
CACHES = {
|
|
"default": {
|
|
"BACKEND": "django_redis.cache.RedisCache",
|
|
"LOCATION": values.Value(
|
|
"redis://redis:6379/0",
|
|
environ_name="REDIS_URL",
|
|
environ_prefix=None,
|
|
),
|
|
"TIMEOUT": values.IntegerValue(
|
|
30, # timeout in seconds
|
|
environ_name="CACHES_DEFAULT_TIMEOUT",
|
|
environ_prefix=None,
|
|
),
|
|
"OPTIONS": {
|
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
|
},
|
|
"KEY_PREFIX": values.Value(
|
|
"docs",
|
|
environ_name="CACHES_DEFAULT_KEY_PREFIX",
|
|
environ_prefix=None,
|
|
),
|
|
},
|
|
"session": {
|
|
"BACKEND": "django_redis.cache.RedisCache",
|
|
"LOCATION": values.Value(
|
|
"redis://redis:6379/0",
|
|
environ_name="REDIS_URL",
|
|
environ_prefix=None,
|
|
),
|
|
"TIMEOUT": values.IntegerValue(
|
|
30, # timeout in seconds
|
|
environ_name="CACHES_SESSION_TIMEOUT",
|
|
environ_prefix=None,
|
|
),
|
|
"OPTIONS": {
|
|
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
class Feature(Production):
|
|
"""
|
|
Feature environment settings
|
|
|
|
nota bene: it should inherit from the Production environment.
|
|
"""
|
|
|
|
|
|
class Staging(Production):
|
|
"""
|
|
Staging environment settings
|
|
|
|
nota bene: it should inherit from the Production environment.
|
|
"""
|
|
|
|
|
|
class PreProduction(Production):
|
|
"""
|
|
Pre-production environment settings
|
|
|
|
nota bene: it should inherit from the Production environment.
|
|
"""
|
|
|
|
|
|
class Demo(Production):
|
|
"""
|
|
Demonstration environment settings
|
|
|
|
nota bene: it should inherit from the Production environment.
|
|
"""
|
|
|
|
STORAGES = {
|
|
"default": {
|
|
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
|
},
|
|
"staticfiles": {
|
|
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
|
},
|
|
}
|