From 6506b8058a663b13f10506f358ff18d076cda4fc Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Wed, 29 Jul 2026 12:02:11 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8(backend)=20support=20file=20items?= =?UTF-8?q?=20pointing=20to=20external=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resources hosted by other applications (e.g. Docs documents) need to live in the Drive tree next to regular files. A metadata.external_app marker turns a FILE item into a pointer: it carries no filename and no physical upload, is ready as soon as created, and can hold children so an app can model sub-resources. Abilities gate every file-only feature (download, wopi, upload, convert) and soft delete now propagates to descendants of such items like it does for folders. --- src/backend/core/api/filters.py | 3 ++ src/backend/core/api/serializers.py | 19 ++++++++-- .../core/migrations/0027_item_metadata.py | 18 ++++++++++ src/backend/core/models.py | 35 ++++++++++++++----- .../core/tests/items/test_api_items_list.py | 3 ++ src/backend/wopi/utils/__init__.py | 1 + 6 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 src/backend/core/migrations/0027_item_metadata.py diff --git a/src/backend/core/api/filters.py b/src/backend/core/api/filters.py index a9f908613..64fab631d 100644 --- a/src/backend/core/api/filters.py +++ b/src/backend/core/api/filters.py @@ -22,6 +22,9 @@ class ItemFilter(django_filters.FilterSet): title = django_filters.CharFilter( field_name="title", lookup_expr="unaccent__icontains", label=_("Title") ) + external_app = django_filters.CharFilter( + field_name="metadata__external_app", label=_("External app") + ) category = django_filters.ChoiceFilter( method="filter_category", label=_("File type"), choices=enums.FILE_CATEGORY_CHOICES ) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index cbddbf8d3..bdd47e1e2 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -268,6 +268,7 @@ class Meta: "deleted_at", "hard_delete_at", "is_wopi_supported", + "metadata", ] read_only_fields = [ "id", @@ -298,6 +299,7 @@ class Meta: "deleted_at", "hard_delete_at", "is_wopi_supported", + "metadata", ] def to_representation(self, instance): @@ -421,6 +423,7 @@ class Meta: "deleted_at", "hard_delete_at", "is_wopi_supported", + "metadata", ] read_only_fields = [ "id", @@ -446,6 +449,7 @@ class Meta: "deleted_at", "hard_delete_at", "is_wopi_supported", + "metadata", ] @@ -498,6 +502,7 @@ class Meta: "deleted_at", "hard_delete_at", "is_wopi_supported", + "metadata", ] read_only_fields = [ "id", @@ -528,6 +533,7 @@ class Meta: "deleted_at", "hard_delete_at", "is_wopi_supported", + "metadata", ] def create(self, validated_data): @@ -539,7 +545,7 @@ def update(self, instance, validated_data): if instance.depth > 1: validated_data["title"] = instance.manage_unique_title(validated_data.get("title")) - if instance.type == models.ItemTypeChoices.FILE: + if instance.type == models.ItemTypeChoices.FILE and not instance.is_external: # Just check for validation, the real filename # will be use later in the rename_file task utils.sanitize_filename(validated_data["title"]) @@ -601,6 +607,7 @@ class Meta: "description", "hard_delete_at", "extension", + "metadata", ] read_only_fields = [ "abilities", @@ -651,8 +658,16 @@ def validate_id(self, value): def validate(self, attrs): """Validate that filename is set for files.""" extension = attrs.get("extension") + is_external = bool((attrs.get("metadata") or {}).get("external_app")) - if attrs["type"] == models.ItemTypeChoices.FILE: + if attrs["type"] == models.ItemTypeChoices.FILE and is_external: + # External items point to a resource hosted by another app: they have + # no filename and no physical file, only a title. + if not attrs.get("title"): + raise serializers.ValidationError( + {"title": _("This field is required.")}, + ) + elif attrs["type"] == models.ItemTypeChoices.FILE: if extension: # Template-based creation: title is required, filename is computed if not attrs.get("title"): diff --git a/src/backend/core/migrations/0027_item_metadata.py b/src/backend/core/migrations/0027_item_metadata.py new file mode 100644 index 000000000..248130556 --- /dev/null +++ b/src/backend/core/migrations/0027_item_metadata.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-07-24 15:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0026_item_item_creator_size_not_hdel_idx'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='metadata', + field=models.JSONField(blank=True, default=dict, help_text='Metadata describing an external resource this item points to.', null=True), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 993f8502f..8c29db36c 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -955,7 +955,7 @@ def create_child(self, parent=None, **kwargs): unique in the same path. """ if parent: - if parent.type != ItemTypeChoices.FOLDER: + if parent.type != ItemTypeChoices.FOLDER and not parent.is_external: raise ValidationError( { "type": ValidationError( @@ -1028,6 +1028,12 @@ class Item(TreeModel, BaseModel): default=dict, help_text=_("Malware detection info when the analysis status is unsafe."), ) + metadata = models.JSONField( + null=True, + blank=True, + default=dict, + help_text=_("Metadata describing an external resource this item points to."), + ) label_size = 7 @@ -1068,11 +1074,16 @@ def __init__(self, *args, **kwargs): self._ancestors_link_definition = None self._computed_link_definition = None + @property + def is_external(self): + """An item is external when it points to a resource hosted by another app.""" + return bool(self.metadata and self.metadata.get("external_app")) + def save(self, *args, **kwargs): """Set the upload state to pending if it's the first save and it's a file""" # Validate filename requirements based on item type if self.type == ItemTypeChoices.FILE: - if self.filename is None: + if self.filename is None and not self.is_external: raise ValidationError( { "filename": ValidationError( @@ -1100,7 +1111,12 @@ def save(self, *args, **kwargs): ItemUploadStateChoices.CONVERTING, ) ): - self.upload_state = ItemUploadStateChoices.PENDING + # External items have no physical file to upload: they are ready as is. + self.upload_state = ( + ItemUploadStateChoices.READY + if self.is_external + else ItemUploadStateChoices.PENDING + ) if not self.path: self.path = str(self.id) @@ -1365,10 +1381,12 @@ def get_abilities(self, user): and user.is_authenticated and self.type == ItemTypeChoices.FILE and self.upload_state == ItemUploadStateChoices.READY + and not self.is_external ) can_export = can_get and self.type == ItemTypeChoices.FOLDER can_convert = ( can_update + and not self.is_external and self.type == ItemTypeChoices.FILE and self.upload_state in ( @@ -1386,7 +1404,7 @@ def get_abilities(self, user): "children_list": can_get, "children_create": can_create_children, "destroy": can_destroy, - "download": can_get, + "download": can_get and not self.is_external, "duplicate": can_duplicate, "export": can_export, "hard_delete": can_hard_delete, @@ -1401,8 +1419,8 @@ def get_abilities(self, user): "media_auth": can_get, "partial_update": can_update, "update": can_update, - "upload_ended": can_update and user.is_authenticated, - "wopi": can_get, + "upload_ended": can_update and user.is_authenticated and not self.is_external, + "wopi": can_get and not self.is_external, "convert": can_convert, } @@ -1488,8 +1506,9 @@ def soft_delete(self): self.save(update_fields=["deleted_at", "ancestors_deleted_at"]) - # Mark all descendants as soft deleted - if self.type == ItemTypeChoices.FOLDER: + # Mark all descendants as soft deleted. External FILE items can carry + # descendants too (e.g. sub-documents of a Docs document). + if self.type == ItemTypeChoices.FOLDER or self.is_external: self.descendants().filter(ancestors_deleted_at__isnull=True).update( ancestors_deleted_at=self.ancestors_deleted_at, ) diff --git a/src/backend/core/tests/items/test_api_items_list.py b/src/backend/core/tests/items/test_api_items_list.py index b3e01478b..6bee2a1f5 100644 --- a/src/backend/core/tests/items/test_api_items_list.py +++ b/src/backend/core/tests/items/test_api_items_list.py @@ -128,6 +128,7 @@ def test_api_items_list_format(): "deleted_at": None, "hard_delete_at": None, "is_wopi_supported": False, + "metadata": {}, }, { "id": str(item2.id), @@ -166,6 +167,7 @@ def test_api_items_list_format(): "deleted_at": None, "hard_delete_at": None, "is_wopi_supported": False, + "metadata": {}, }, { "id": str(item.id), @@ -204,6 +206,7 @@ def test_api_items_list_format(): "deleted_at": None, "hard_delete_at": None, "is_wopi_supported": False, + "metadata": {}, }, ] diff --git a/src/backend/wopi/utils/__init__.py b/src/backend/wopi/utils/__init__.py index bf2834b09..d8c5295fc 100644 --- a/src/backend/wopi/utils/__init__.py +++ b/src/backend/wopi/utils/__init__.py @@ -28,6 +28,7 @@ def get_wopi_client_config(item, user): """ if ( item.type != models.ItemTypeChoices.FILE + or item.filename is None or item.upload_state == models.ItemUploadStateChoices.SUSPICIOUS or (item.creator != user and item.upload_state != models.ItemUploadStateChoices.READY) ): From 3d9c8573758db70e5f45896037efa0ae81da1876 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Wed, 29 Jul 2026 12:02:38 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8(backend)=20let=20trusted=20apps?= =?UTF-8?q?=20impersonate=20users=20server-to-server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External apps integrated in the tree must enforce the exact same permissions as Drive without duplicating them. A static bearer token identifies the trusted app and the X-User-Sub/X-User-Email headers name the acting user, so every existing endpoint keeps its permission logic. Without identity headers the caller acts for an anonymous visitor, which makes public-link reach work end to end. The returned "s2s" auth tag lets views detect these calls to prevent notification loops. --- .../core/authentication/server_to_server.py | 48 +++++++++++++++++++ src/backend/drive/settings.py | 9 ++++ 2 files changed, 57 insertions(+) create mode 100644 src/backend/core/authentication/server_to_server.py diff --git a/src/backend/core/authentication/server_to_server.py b/src/backend/core/authentication/server_to_server.py new file mode 100644 index 000000000..0731153e6 --- /dev/null +++ b/src/backend/core/authentication/server_to_server.py @@ -0,0 +1,48 @@ +"""Server-to-server authentication with user impersonation.""" + +from django.conf import settings +from django.contrib.auth.models import AnonymousUser + +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + +from core import models + + +class ServerToServerUserAuthentication(BaseAuthentication): + """ + Authenticate trusted applications with a static bearer token and impersonate + the acting user passed in the "X-User-Sub" / "X-User-Email" headers. + + Returns None when the bearer token is not a known server-to-server token so + the next authentication classes (OIDC, session) get a chance to run. + """ + + def authenticate(self, request): + auth_header = request.headers.get("Authorization", "") + parts = auth_header.split(" ") + + if len(parts) != 2 or parts[0] != "Bearer": + return None + + if parts[1] not in settings.SERVER_TO_SERVER_API_TOKENS: + return None + + sub = request.headers.get("X-User-Sub") + email = request.headers.get("X-User-Email") + + if not sub and not email: + # The calling application acts for an anonymous visitor: abilities + # are computed with public-link semantics only. + return (AnonymousUser(), "s2s") + + user = None + if sub: + user = models.User.objects.filter(sub=sub).first() + if user is None and email: + user = models.User.objects.filter(email__iexact=email).first() + + if user is None: + raise AuthenticationFailed("Unknown acting user for server-to-server request.") + + return (user, "s2s") diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index 977d5bea7..40d9fecae 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -901,8 +901,17 @@ class Base(Configuration): }, } + # Static bearer tokens allowing trusted applications (e.g. Docs) to call the + # API server-to-server while impersonating a user via X-User-Sub/X-User-Email. + SERVER_TO_SERVER_API_TOKENS = values.ListValue( + [], + environ_name="SERVER_TO_SERVER_API_TOKENS", + environ_prefix=None, + ) + REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( + "core.authentication.server_to_server.ServerToServerUserAuthentication", "mozilla_django_oidc.contrib.drf.OIDCAuthentication", "rest_framework.authentication.SessionAuthentication", ), From 0fc3557d2ce0270f6ef32714fcbe36b0f2a487ce Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Wed, 29 Jul 2026 12:02:49 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8(backend)=20add=20a=20lifecycle=20?= =?UTF-8?q?contract=20for=20external=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrating a new application must not require touching the item views. Each app configured in EXTERNAL_APPS provides a backend implementing a small contract (create, delete, restore, purge resources) resolved by name from the item metadata. The Docs backend is the first implementation, calling the Docs server-to-server endpoints. The FRONTEND_DOCS_URL setting is kept as a real setting because the config endpoint exposes it to the frontend. --- .../core/services/external_apps/__init__.py | 6 ++ .../core/services/external_apps/base.py | 41 +++++++++++++ .../core/services/external_apps/docs.py | 61 +++++++++++++++++++ .../core/services/external_apps/registry.py | 20 ++++++ src/backend/drive/settings.py | 25 ++++++++ 5 files changed, 153 insertions(+) create mode 100644 src/backend/core/services/external_apps/__init__.py create mode 100644 src/backend/core/services/external_apps/base.py create mode 100644 src/backend/core/services/external_apps/docs.py create mode 100644 src/backend/core/services/external_apps/registry.py diff --git a/src/backend/core/services/external_apps/__init__.py b/src/backend/core/services/external_apps/__init__.py new file mode 100644 index 000000000..fc22f30e4 --- /dev/null +++ b/src/backend/core/services/external_apps/__init__.py @@ -0,0 +1,6 @@ +"""External app backends: apps whose resources are pointed to by Drive items.""" + +from .base import ExternalAppBackend, ExternalAppError +from .registry import get_external_app_backend + +__all__ = ["ExternalAppBackend", "ExternalAppError", "get_external_app_backend"] diff --git a/src/backend/core/services/external_apps/base.py b/src/backend/core/services/external_apps/base.py new file mode 100644 index 000000000..3d015056c --- /dev/null +++ b/src/backend/core/services/external_apps/base.py @@ -0,0 +1,41 @@ +"""External app backend base class.""" + +from abc import ABC, abstractmethod + + +class ExternalAppError(Exception): + """Raised when a call to an external app fails.""" + + +class ExternalAppBackend(ABC): + """ + Abstract base class for external app backends. + + An external app owns the physical resources that Drive items of type FILE + with `metadata.external_app == ` point to. Drive owns the tree, + sharing and trash; the backend keeps the external app's resources in sync + with the lifecycle of their pointer items. + """ + + def __init__(self, name, api_base_url, token, frontend_url=None, timeout=10, **kwargs): + self.name = name + self.api_base_url = api_base_url.rstrip("/") if api_base_url else None + self.token = token + self.frontend_url = frontend_url + self.timeout = timeout + + @abstractmethod + def create_resource(self, item, user): + """ + Create the external resource backing `item` on behalf of `user`. + Returns the created payload. + """ + + def delete_resources(self, item_ids): + """Soft-delete the external resources for the given item ids. No-op by default.""" + + def restore_resources(self, item_ids): + """Restore soft-deleted external resources. No-op by default.""" + + def purge_resources(self, item_ids): + """Permanently destroy external resources and their storage. No-op by default.""" diff --git a/src/backend/core/services/external_apps/docs.py b/src/backend/core/services/external_apps/docs.py new file mode 100644 index 000000000..eac617a6f --- /dev/null +++ b/src/backend/core/services/external_apps/docs.py @@ -0,0 +1,61 @@ +"""Docs external app backend.""" + +import logging + +import requests + +from .base import ExternalAppBackend, ExternalAppError + +logger = logging.getLogger(__name__) + + +class DocsExternalAppBackend(ExternalAppBackend): + """Back-channel client for the Docs application.""" + + def _post(self, path, payload): + """POST to the Docs server-to-server API.""" + if not self.api_base_url or not self.token: + raise ExternalAppError("Docs integration is not configured.") + + url = f"{self.api_base_url}{path}" + try: + response = requests.post( + url, + json=payload, + headers={"Authorization": f"Bearer {self.token}"}, + timeout=self.timeout, + ) + except requests.RequestException as exc: + raise ExternalAppError(f"Could not reach Docs: {exc}") from exc + + if response.status_code >= 400: + raise ExternalAppError( + f"Docs call failed ({response.status_code}) on {path}: " + f"{response.text[:500]}" + ) + + return response.json() + + def create_resource(self, item, user): + """Create the Docs document matching a freshly created external item.""" + return self._post( + "/documents/create-for-owner/", + { + "id": str(item.id), + "title": item.title, + "sub": user.sub, + "email": user.email, + }, + ) + + def delete_resources(self, item_ids): + """Move the matching Docs documents to their soft-deleted state.""" + return self._post("/documents/s2s-delete/", {"ids": [str(i) for i in item_ids]}) + + def restore_resources(self, item_ids): + """Restore the matching soft-deleted Docs documents.""" + return self._post("/documents/s2s-restore/", {"ids": [str(i) for i in item_ids]}) + + def purge_resources(self, item_ids): + """Permanently destroy the matching Docs documents and their storage.""" + return self._post("/documents/s2s-purge/", {"ids": [str(i) for i in item_ids]}) diff --git a/src/backend/core/services/external_apps/registry.py b/src/backend/core/services/external_apps/registry.py new file mode 100644 index 000000000..77226a506 --- /dev/null +++ b/src/backend/core/services/external_apps/registry.py @@ -0,0 +1,20 @@ +"""External app backend registry.""" + +import functools + +from django.conf import settings +from django.utils.module_loading import import_string + + +@functools.cache +def get_external_app_backend(name): + """ + Return the configured backend for an external app name, or None when the + app is not declared in settings.EXTERNAL_APPS. + """ + config = settings.EXTERNAL_APPS.get(name) + if not config: + return None + + kwargs = {key: value for key, value in config.items() if key != "backend"} + return import_string(config["backend"])(name=name, **kwargs) diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index 40d9fecae..62417edb2 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -909,6 +909,31 @@ class Base(Configuration): environ_prefix=None, ) + # Base URL of the Docs frontend, used by the Drive frontend to open + # documents (exposed through the config endpoint). + FRONTEND_DOCS_URL = values.Value( + None, + environ_name="FRONTEND_DOCS_URL", + environ_prefix=None, + ) + + # Registry of external apps whose resources are pointed to by Drive items + # (metadata.external_app). Each entry configures a backend class handling + # the back-channel lifecycle calls (create/delete/restore/purge). + # The default sources the legacy DOCS_* env vars for compatibility. + EXTERNAL_APPS = values.DictValue( + default={ + "docs": { + "backend": "core.services.external_apps.docs.DocsExternalAppBackend", + "api_base_url": os.environ.get("DOCS_API_BASE_URL"), + "token": os.environ.get("DOCS_SERVER_TO_SERVER_TOKEN"), + "frontend_url": os.environ.get("FRONTEND_DOCS_URL"), + } + }, + environ_name="EXTERNAL_APPS", + environ_prefix=None, + ) + REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "core.authentication.server_to_server.ServerToServerUserAuthentication", From 8c954c016f729589b55e01caea18ed06b05d9ae9 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Wed, 29 Jul 2026 12:03:02 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9C=A8(backend)=20sync=20the=20item=20li?= =?UTF-8?q?fecycle=20with=20external=20apps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The external app must mirror what happens to its pointer items in Drive. Creating one from Drive triggers the app back-channel (rolled back if the app fails), and trash, restore and purge notify the app for every pointer of the affected subtree. The purge task carries a backstop notification so the cron path is covered too; app endpoints are idempotent so retries are safe. Server-to-server calls skip all hooks because the app initiated the operation itself. Sub-documents (children of a pointer item) are hidden from all listing views so only the document root surfaces in the UI, while the new tree-descendants action returns the whole document tree to the app. --- src/backend/core/api/permissions.py | 1 + src/backend/core/api/viewsets.py | 158 ++++++++++++++++++++++++++++ src/backend/core/tasks/item.py | 25 ++++- 3 files changed, 183 insertions(+), 1 deletion(-) diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index 1e8aad414..0280147fd 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -11,6 +11,7 @@ ACTION_FOR_METHOD_TO_PERMISSION = { "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}, "children": {"GET": "children_list", "POST": "children_create"}, + "tree_descendants": {"GET": "tree"}, } diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index bf9d6ba1f..d7c6fc456 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -46,6 +46,8 @@ from rest_framework_api_key.permissions import HasAPIKey from core import enums, models +from core.services import external_apps +from core.services.external_apps import get_external_app_backend from core.entitlements import get_entitlements_backend from core.services.item_exports import build_zip_stream, export_descendants from core.services.sdk_relay import SDKRelayManager @@ -450,6 +452,24 @@ def _exclude_pending_items(self, queryset): """Exclude items with PENDING upload_state from listing views.""" return queryset.exclude(upload_state=models.ItemUploadStateChoices.PENDING) + def _exclude_external_descendants(self, queryset): + """ + Hide items living under an external-app item (e.g. sub-documents of a + Docs document) from listing views: only the pointer item itself should + surface in the Drive UI. External apps calling us server-to-server + must keep seeing them. + """ + if self.request.auth == "s2s": + return queryset + + # ltree "path__ancestors" includes the item itself: exclude self so the + # pointer item stays visible while its descendants are hidden. + external_ancestor = models.Item.objects.filter( + path__ancestors=db.OuterRef("path"), + metadata__external_app__isnull=False, + ).exclude(pk=db.OuterRef("pk")) + return queryset.exclude(db.Exists(external_ancestor)) + def get_queryset(self): """Get queryset performing all annotation and filtering on the item tree structure.""" user = self.request.user @@ -660,6 +680,39 @@ def _create_file_from_template(self, item, extension): item.size = len(template_content) item.save(update_fields=["upload_state", "mimetype", "size", "updated_at"]) + def _sync_external_document(self, item): + """ + When an external item is created from Drive, create the matching + resource in the external app through its back-channel backend. + + Skipped for server-to-server requests: the item creation was initiated + by the external app itself, which already owns the resource. + """ + if self.request.auth == "s2s": + return + + app_name = (item.metadata or {}).get("external_app") + if not app_name: + return + + backend = get_external_app_backend(app_name) + if backend is None: + logger.warning("No backend configured for external app %s", app_name) + return + + try: + backend.create_resource(item, self.request.user) + except external_apps.ExternalAppError as exc: + logger.error( + "External app %s creation failed for item %s: %s", + app_name, + item.id, + exc, + ) + item.soft_delete() + item.delete() + raise drf.exceptions.APIException(detail=str(exc)) from exc + def perform_create(self, serializer): """Set the current user as creator and owner of the newly created object.""" extension = serializer.validated_data.pop("extension", None) @@ -677,9 +730,49 @@ def perform_create(self, serializer): user=self.request.user, role=models.RoleChoices.OWNER, ) + self._sync_external_document(obj) + + def _notify_external_apps(self, item, action): + """ + Notify external apps that the pointer items of a deleted/restored + subtree changed state. `action` is one of "delete", "restore", "purge". + + Skipped for server-to-server requests (the external app initiated the + operation itself). Never blocks the Drive operation on failure. + """ + if self.request.auth == "s2s": + return + + pointers = models.Item.objects.filter( + path__descendants=item.path, + metadata__external_app__isnull=False, + hard_deleted_at__isnull=True, + ).values_list("id", "metadata") + + ids_by_app = {} + for item_id, metadata in pointers: + app_name = (metadata or {}).get("external_app") + if app_name: + ids_by_app.setdefault(app_name, []).append(str(item_id)) + + for app_name, ids in ids_by_app.items(): + backend = get_external_app_backend(app_name) + if backend is None: + logger.warning("No backend configured for external app %s", app_name) + continue + try: + getattr(backend, f"{action}_resources")(ids) + except Exception: # noqa: BLE001 pylint: disable=broad-exception-caught + logger.exception( + "External app %s %s notification failed for item %s", + app_name, + action, + item.id, + ) def perform_destroy(self, instance): """Override to implement a soft delete instead of dumping the record in database.""" + self._notify_external_apps(instance, "delete") instance.soft_delete() def perform_update(self, serializer): @@ -698,6 +791,7 @@ def hard_delete(self, request, *args, **kwargs): Hard delete an item. """ instance = self.get_object() + self._notify_external_apps(instance, "purge") instance.hard_delete() process_item_purge.delay(instance.id) return drf.response.Response(status=status.HTTP_204_NO_CONTENT) @@ -738,6 +832,9 @@ def list(self, request, *args, **kwargs): """List top level items with pagination and filtering.""" # Not calling filter_queryset. We do our own cooking. queryset = self.get_queryset() + # Before root filtering, so a directly-shared sub-document can not be + # promoted to a "highest ancestor" in the listing. + queryset = self._exclude_external_descendants(queryset) filterset = ListItemFilter(self.request.GET, queryset=queryset, request=self.request) if not filterset.is_valid(): @@ -903,6 +1000,7 @@ def upload_ended(self, request, *args, **kwargs): def _complete_item_deletion(self, item): """Completely delete an item.""" + self._notify_external_apps(item, "purge") item.soft_delete() item.hard_delete() process_item_purge.delay(item.id) @@ -916,6 +1014,7 @@ def favorite_list(self, request, *args, **kwargs): """Get list of favorite items for the current user.""" user = request.user queryset = self.get_queryset_for_descendants() + queryset = self._exclude_external_descendants(queryset) queryset = queryset.annotate(is_favorite=db.Value(True, output_field=db.BooleanField())) queryset = queryset.annotate_user_roles(user) @@ -961,6 +1060,7 @@ def trashbin(self, request, *args, **kwargs): .filter(deleted_at__gte=models.get_trashbin_cutoff()) .owned_by(user) ) + queryset = self._exclude_external_descendants(queryset) # Apply filtering similar to children method filterset = ItemFilter(request.GET, queryset=queryset) @@ -1076,6 +1176,7 @@ def restore(self, request, *args, **kwargs): """ item = self.get_object() item.restore() + self._notify_external_apps(item, "restore") return drf_response.Response( {"detail": "item has been successfully restored."}, @@ -1099,10 +1200,14 @@ def children(self, request, *args, **kwargs): ) serializer.is_valid(raise_exception=True) + is_external = bool( + (serializer.validated_data.get("metadata") or {}).get("external_app") + ) entitlements_backend = get_entitlements_backend() can_upload = entitlements_backend.can_upload(self.request.user) if ( serializer.validated_data.get("type") == models.ItemTypeChoices.FILE + and not is_external and not can_upload["result"] ): raise drf.exceptions.PermissionDenied( @@ -1121,6 +1226,8 @@ def children(self, request, *args, **kwargs): if extension: self._create_file_from_template(child_item, extension) + self._sync_external_document(child_item) + # Set the created instance to the serializer serializer.instance = child_item @@ -1251,6 +1358,54 @@ def tree(self, request, pk=None): utils.flat_to_nested(serializer.data), status=drf.status.HTTP_200_OK ) + @drf.decorators.action(detail=True, methods=["get"], url_path="tree-descendants") + def tree_descendants(self, request, pk=None): + """ + Return the full descendants tree of an external item as a nested structure. + + The subtree root is the topmost readable ancestor sharing the same + external_app, so requesting any node of a document tree returns the + whole document tree. + """ + item = self.get_object() + + external_app = (item.metadata or {}).get("external_app") + + root = item + if external_app: + root = ( + self.queryset.filter( + path__ancestors=item.path, + metadata__external_app=external_app, + ancestors_deleted_at__isnull=True, + ) + .readable_per_se(request.user) + .order_by("path") + .first() + ) or item + + queryset = ( + self.queryset.select_related("creator") + .filter( + path__descendants=root.path, + deleted_at__isnull=True, + ancestors_deleted_at__isnull=True, + ) + .order_by("path") + ) + queryset = self._filter_suspicious_items(queryset, request.user) + queryset = queryset.annotate_user_roles(request.user) + queryset = queryset.annotate_is_favorite(request.user) + queryset = queryset.annotate_with_numchild() + + serializer = serializers.ListItemSerializer( + queryset, many=True, context={"request": request} + ) + + return drf.response.Response( + utils.flat_to_nested(serializer.data), status=drf.status.HTTP_200_OK + ) + @drf.decorators.action( url_path="recents", detail=False, @@ -1261,6 +1416,7 @@ def recents(self, request, *args, **kwargs): """Get list of recents items for the current user.""" user = self.request.user queryset = self.get_queryset_for_descendants() + queryset = self._exclude_external_descendants(queryset) filterset = ItemFilter(self.request.GET, queryset=queryset, request=self.request) if not filterset.is_valid(): @@ -1411,6 +1567,7 @@ def search(self, request, *args, **kwargs): path_list |= db.Q(path__descendants=top_level_item) queryset = queryset.filter(path_list) + queryset = self._exclude_external_descendants(queryset) # use indexed search ONLY when the feature flag is enabled if indexer and settings.FEATURES_INDEXED_SEARCH is True: @@ -2292,6 +2449,7 @@ def get(self, request): "FRONTEND_STORAGE_GAUGE_INFORMATION_LINK", "FRONTEND_CSS_URL", "FRONTEND_JS_URL", + "FRONTEND_DOCS_URL", "MEDIA_BASE_URL", "POSTHOG_KEY", "POSTHOG_HOST", diff --git a/src/backend/core/tasks/item.py b/src/backend/core/tasks/item.py index 91ef91bc3..58489c3b2 100644 --- a/src/backend/core/tasks/item.py +++ b/src/backend/core/tasks/item.py @@ -17,6 +17,7 @@ from core.api.utils import sanitize_filename from core.models import Item, ItemTypeChoices, ItemUploadStateChoices +from core.services.external_apps import get_external_app_backend from drive.celery_app import app @@ -60,8 +61,14 @@ def process_item_purge(item_id): return # Get descendants, leaf first. Don't burst memory + external_ids_by_app = {} for item in Item.objects.filter(path__descendants=root.path).order_by("-path").iterator(): - if item.type == ItemTypeChoices.FILE and item.file_key: + if item.is_external: + # External items have no physical file in Drive; their app purges + # its own storage through the backstop notification below. + app_name = item.metadata.get("external_app") + external_ids_by_app.setdefault(app_name, []).append(str(item.id)) + elif item.type == ItemTypeChoices.FILE and item.file_key: try: default_storage.delete(item.file_key) except FileNotFoundError: @@ -73,6 +80,22 @@ def process_item_purge(item_id): item.delete() + # Guaranteed external-app purge notification: covers the cron path and + # retries. External apps' purge endpoints are idempotent. + for app_name, ids in external_ids_by_app.items(): + backend = get_external_app_backend(app_name) + if backend is None: + logger.warning("No backend configured for external app %s", app_name) + continue + try: + backend.purge_resources(ids) + except Exception: # noqa: BLE001 pylint: disable=broad-exception-caught + logger.exception( + "External app %s purge notification failed for root %s", + app_name, + item_id, + ) + @app.task def rename_file(item_id, new_title): From 00a969798b83db9e0845d64c8d14b38177531ee4 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Wed, 29 Jul 2026 12:03:11 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=A8(frontend)=20create=20and=20open?= =?UTF-8?q?=20Docs=20documents=20from=20the=20explorer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs documents live in the tree as pointer items, so the explorer needs to treat them specially: a dedicated create menu entry posts an item carrying the docs metadata (same UX as template files, no auto-open), the grid shows the Docs icon, and clicking one opens the Docs frontend instead of the preview. The file preview route hands over to Docs too, so shared links to the item keep working. --- .../apps/drive/src/features/drivers/Driver.ts | 3 ++- .../drivers/implementations/StandardDriver.ts | 6 +++-- .../apps/drive/src/features/drivers/types.ts | 4 ++++ .../components/app-view/AppExplorerGrid.tsx | 14 ++++++++++++ .../explorer/components/icons/ItemIcon.tsx | 6 ++++- .../modals/ExplorerCreateFileModal.tsx | 7 ++++++ .../explorer/hooks/useCreateMenuItems.tsx | 20 ++++++++++++++++- .../drive/src/features/i18n/translations.json | 18 ++++++++++----- .../src/pages/explorer/items/files/[id].tsx | 22 ++++++++++++++++++- 9 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index 17329a109..01f1d7e3a 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -175,8 +175,9 @@ export abstract class Driver { }): { promise: Promise; abort: () => Promise }; abstract createFileFromTemplate(data: { parentId: string; - extension: string; + extension?: string; title: string; + metadata?: { external_app?: string }; }): Promise; abstract duplicateItem(id: string): Promise; abstract deleteItems(ids: string[]): Promise; diff --git a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts index ea3b19325..d59776337 100644 --- a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts +++ b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts @@ -450,8 +450,9 @@ export class StandardDriver extends Driver { async createFileFromTemplate(data: { parentId?: string; - extension: string; + extension?: string; title: string; + metadata?: { external_app?: string }; }): Promise { const url = data.parentId ? `items/${data.parentId}/children/` : `items/`; @@ -461,7 +462,8 @@ export class StandardDriver extends Driver { method: "POST", body: JSON.stringify({ type: "file", - extension: data.extension, + ...(data.extension ? { extension: data.extension } : {}), + ...(data.metadata ? { metadata: data.metadata } : {}), title: data.title, }), }, diff --git a/src/frontend/apps/drive/src/features/drivers/types.ts b/src/frontend/apps/drive/src/features/drivers/types.ts index 0d1782c11..d326e90b1 100644 --- a/src/frontend/apps/drive/src/features/drivers/types.ts +++ b/src/frontend/apps/drive/src/features/drivers/types.ts @@ -84,6 +84,9 @@ export type Item = { url_preview?: string; size?: number; mimetype?: string; + metadata?: { + external_app?: string; + }; user_roles?: Role[]; user_role?: Role; link_reach?: LinkReach; @@ -237,6 +240,7 @@ export type ApiConfig = { FRONTEND_STORAGE_GAUGE_INFORMATION_LINK?: string; FRONTEND_CSS_URL?: string; FRONTEND_JS_URL?: string; + FRONTEND_DOCS_URL?: string; theme_customization?: ThemeCustomization; }; diff --git a/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorerGrid.tsx b/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorerGrid.tsx index 4b65aed33..aa8a0fa8d 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorerGrid.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/app-view/AppExplorerGrid.tsx @@ -20,6 +20,7 @@ import { openWopiInNewTab } from "@/features/wopi/openWopi"; import { itemToPreviewFile } from "@/features/explorer/utils/utils"; import { useModal } from "@gouvfr-lasuite/cunningham-react"; import { ConvertLegacyFileModal } from "@/features/explorer/components/modals/ConvertLegacyFileModal"; +import { useConfig } from "@/features/config/ConfigProvider"; /** * Wrapper around EmbeddedExplorerGrid to display a list of items in a table. @@ -33,6 +34,7 @@ import { ConvertLegacyFileModal } from "@/features/explorer/components/modals/Co export const AppExplorerGrid = () => { const { t } = useTranslation(); const appExplorer = useAppExplorer(); + const { config } = useConfig(); const router = useRouter(); @@ -53,6 +55,18 @@ export const AppExplorerGrid = () => { const handleFileClick = appExplorer.onFileClick ?? ((item: Item) => { + if (item.metadata?.external_app === "docs") { + if (config.FRONTEND_DOCS_URL) { + window.open( + `${config.FRONTEND_DOCS_URL}/docs/${item.id}/`, + "_blank", + "noopener", + ); + } else { + addToast({t("explorer.grid.no_url")}); + } + return; + } if (item.abilities.convert) { setItemToConvert(item); convertModal.open(); diff --git a/src/frontend/apps/drive/src/features/explorer/components/icons/ItemIcon.tsx b/src/frontend/apps/drive/src/features/explorer/components/icons/ItemIcon.tsx index e13a3a1e0..79d22abc3 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/icons/ItemIcon.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/icons/ItemIcon.tsx @@ -26,7 +26,7 @@ export const ItemIcon = ({ if (extendedIcon) { return ; } - return ; + return ; }; /** @@ -45,6 +45,10 @@ export const getItemExtendedIcon = ( return folderIcon.src; } + if (item.metadata?.external_app === "docs") { + return ICONS[type][MimeCategory.DOCS]; + } + const uploadState = item.upload_state; if (uploadState === ItemUploadState.SUSPICIOUS) { return ICONS[type][MimeCategory.SUSPICIOUS]; diff --git a/src/frontend/apps/drive/src/features/explorer/components/modals/ExplorerCreateFileModal.tsx b/src/frontend/apps/drive/src/features/explorer/components/modals/ExplorerCreateFileModal.tsx index 503d61b41..197451773 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/modals/ExplorerCreateFileModal.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/modals/ExplorerCreateFileModal.tsx @@ -19,6 +19,7 @@ export enum ExplorerCreateFileType { DOC = "doc", POWERPOINT = "powerpoint", CALC = "calc", + DOCS_DOCUMENT = "docs", } const getExtension = (type: ExplorerCreateFileType) => { @@ -29,6 +30,9 @@ const getExtension = (type: ExplorerCreateFileType) => { return "odp"; case ExplorerCreateFileType.CALC: return "ods"; + case ExplorerCreateFileType.DOCS_DOCUMENT: + // Docs documents are external items: no template file, no extension. + return undefined; } }; @@ -53,6 +57,9 @@ export const ExplorerCreateFileModal = ( parentId: props.parentId, extension: extension, title: data.filename, + ...(props.type === ExplorerCreateFileType.DOCS_DOCUMENT + ? { metadata: { external_app: "docs" } } + : {}), }, { onSuccess: (createdItem) => { diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useCreateMenuItems.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useCreateMenuItems.tsx index 543ca91c4..d3fa42d27 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useCreateMenuItems.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useCreateMenuItems.tsx @@ -1,4 +1,10 @@ -import { MenuItem, IconSize } from "@gouvfr-lasuite/ui-kit"; +import { + MenuItem, + IconSize, + FileIconContent, + ICONS, + MimeCategory, +} from "@gouvfr-lasuite/ui-kit"; import { useTranslation } from "react-i18next"; import { useGlobalExplorer } from "@/features/explorer/components/GlobalExplorerContext"; import createFolderSvg from "@/assets/icons/create_folder.svg"; @@ -89,6 +95,18 @@ export const useCreateMenuItems = ({ if (includeCreate) { items.push( + { + icon: ( + + ), + label: t("explorer.tree.create.file.docs_document"), + callback: () => + openCreateFileModal(ExplorerCreateFileType.DOCS_DOCUMENT), + }, + { type: "separator" }, { icon: renderFileIcon({ type: ItemType.FILE, diff --git a/src/frontend/apps/drive/src/features/i18n/translations.json b/src/frontend/apps/drive/src/features/i18n/translations.json index 4d4a7de46..9fe8165c5 100644 --- a/src/frontend/apps/drive/src/features/i18n/translations.json +++ b/src/frontend/apps/drive/src/features/i18n/translations.json @@ -362,7 +362,8 @@ "file": { "doc": "New text document", "powerpoint": "New slides", - "calc": "New spreadsheet" + "calc": "New spreadsheet", + "docs_document": "New Docs document" } }, "import": { @@ -504,7 +505,8 @@ "label": "File name", "placeholder": "Enter file name", "cancel": "Cancel", - "submit": "Create" + "submit": "Create", + "title_docs": "New Docs document" } }, "upload": { @@ -1084,7 +1086,8 @@ "file": { "doc": "Nouveau document texte", "powerpoint": "Nouvelles diapositives", - "calc": "Nouveau tableau de calcul" + "calc": "Nouveau tableau de calcul", + "docs_document": "Nouveau Docs" }, "workspace": "Nouvel espace" }, @@ -1213,7 +1216,8 @@ "label": "Nom du fichier", "placeholder": "Entrez le nom du fichier", "cancel": "Annuler", - "submit": "Créer" + "submit": "Créer", + "title_docs": "Nouveau Docs" } }, "upload": { @@ -1779,7 +1783,8 @@ "file": { "doc": "Nieuw tekstdocument", "powerpoint": "Nieuwe presentatie", - "calc": "Nieuwe spreadsheet" + "calc": "Nieuwe spreadsheet", + "docs_document": "Nieuw Docs" }, "workspace": "Nieuwe werkruimte" }, @@ -1922,7 +1927,8 @@ "label": "Bestandsnaam", "placeholder": "Voer bestandsnaam in", "cancel": "Annuleren", - "submit": "Maken" + "submit": "Maken", + "title_docs": "Nieuw Docs-document" } }, "upload": { diff --git a/src/frontend/apps/drive/src/pages/explorer/items/files/[id].tsx b/src/frontend/apps/drive/src/pages/explorer/items/files/[id].tsx index 3b7cab059..6b3c6e0c9 100644 --- a/src/frontend/apps/drive/src/pages/explorer/items/files/[id].tsx +++ b/src/frontend/apps/drive/src/pages/explorer/items/files/[id].tsx @@ -10,18 +10,38 @@ import { useRouter } from "next/router"; import { useTranslation } from "react-i18next"; import { useItem } from "@/features/explorer/hooks/useQueries"; import { GlobalLayout } from "@/features/layouts/components/global/GlobalLayout"; +import { useConfig } from "@/features/config/ConfigProvider"; +import { useEffect } from "react"; export default function FilePage() { const { t } = useTranslation(); const router = useRouter(); const itemId = router.query.id as string; + const { config } = useConfig(); const { data: item, isLoading, error } = useItem(itemId); + // Items pointing to an external app are not previewable here: hand the + // user over to the owning application. + const externalAppUrl = + item?.metadata?.external_app === "docs" && config?.FRONTEND_DOCS_URL + ? `${config.FRONTEND_DOCS_URL}/docs/${item.id}/` + : null; + + useEffect(() => { + if (externalAppUrl) { + window.location.replace(externalAppUrl); + } + }, [externalAppUrl]); + // On 403, 401, the user is automatically redirected to the 401/403 page. // If the error is a 401 or 403, we want to show the spinner page because an auto redirect is happening. - if (isLoading || (error && [401, 403].includes(error.code))) { + if ( + isLoading || + externalAppUrl || + (error && [401, 403].includes(error.code)) + ) { return ; } From 81aa0e64186c6c826aceafc104fb62e058bfc575 Mon Sep 17 00:00:00 2001 From: Nathan Vasse Date: Wed, 29 Jul 2026 12:03:26 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A7=91=E2=80=8D=F0=9F=92=BB(dev)=20co?= =?UTF-8?q?nfigure=20the=20local=20Docs=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the POC locally needs the two apps to trust each other: a dev placeholder token shared by both directions of the server-to-server API, the Docs URLs resolvable from the compose network, and an impress client in the Keycloak realm so Docs signs in on Drive's identity provider (single session across both apps). --- docker/auth/realm.json | 54 ++++++++++++++++++++++++++++++++++++++++ env.d/development/common | 6 +++++ 2 files changed, 60 insertions(+) diff --git a/docker/auth/realm.json b/docker/auth/realm.json index 776c15827..8048c3044 100644 --- a/docker/auth/realm.json +++ b/docker/auth/realm.json @@ -752,6 +752,60 @@ "microprofile-jwt" ] }, + { + "id": "97a9c274-3f21-4c3e-8f6a-bcadd0e51a02", + "clientId": "impress", + "name": "", + "description": "", + "rootUrl": "", + "adminUrl": "", + "baseUrl": "", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "ThisIsAnExampleKeyForDevPurposeOnly", + "redirectUris": [ + "http://localhost:8072/*", + "http://localhost:3001/*" + ], + "webOrigins": [ + "http://localhost:3001" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "access.token.lifespan": "-1", + "user.info.response.signature.alg": "RS256", + "post.logout.redirect.uris": "http://localhost:3001/*", + "use.refresh.tokens": "true", + "acr.loa.map": "{}" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "roles", + "profile", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, { "id": "869481d0-5774-4e64-bc30-fedc7c58958g", "clientId": "deploycenter", diff --git a/env.d/development/common b/env.d/development/common index 04a9ec2e5..9375196a6 100644 --- a/env.d/development/common +++ b/env.d/development/common @@ -111,3 +111,9 @@ MALWARE_DETECTION_DUMMY_SLEEP=10 # Must be a valid Fernet key (32 url-safe base64-encoded bytes) # To create one, use the bin/fernetkey command. # OIDC_STORE_REFRESH_TOKEN_KEY="your-32-byte-encryption-key==" + +# Docs <-> Drive POC integration +SERVER_TO_SERVER_API_TOKENS=docs-drive-poc-secret +FRONTEND_DOCS_URL=http://localhost:3001 +DOCS_API_BASE_URL=http://docs-app-dev-1:8000/api/v1.0 +DOCS_SERVER_TO_SERVER_TOKEN=docs-drive-poc-secret