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 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/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/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/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/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/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/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/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): 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/drive/settings.py b/src/backend/drive/settings.py index 977d5bea7..62417edb2 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -901,8 +901,42 @@ 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, + ) + + # 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", "mozilla_django_oidc.contrib.drf.OIDCAuthentication", "rest_framework.authentication.SessionAuthentication", ), 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) ): 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 ; }