Skip to content

[RFC] One file representation for DAV and PHP: node attribute providers #65395

Description

@skjnldsv

Tip

Help move this idea forward

  • Use the 👍 reaction to show support for this feature.
  • Avoid commenting unless you have relevant information to add; unnecessary comments create noise for subscribers.
  • Subscribe to receive notifications about status changes and new comments.

Is your feature request related to a problem? Please describe.

The only complete representation of a file is the WebDAV one. Its attributes come from Sabre plugins during a PROPFIND: FilesPlugin, SharesPlugin, TagsPlugin, CommentPropertiesPlugin, SystemTagPlugin, files_reminders, files metadata, and whatever apps add on top. The frontend turns that response into a File/Folder with resultToNode() from @nextcloud/files, and apps add their props on that side with registerDavProperty() (server alone registers 12: nc:sharees, oc:share-types, nc:reminder-due-date, nc:system-tags...).

There is no PHP equivalent. Code that returns files without going through DAV builds its own subset, each with its own shape:

  • apps/files/lib/Helper.php:71 formatFileInfo(): parentId, mtime * 1000, shareOwner, isShareMountPoint, mountType
  • apps/files_sharing/lib/Controller/ShareAPIController.php:204: storage_id, file_target, item_mtime
  • apps/files_trashbin/lib/Helper.php:104: a hand-built mimetype, etag, permissions

None of these carry the attributes plugins add, and none can be turned into the same Node the Files app uses. Each app that needs file data either re-implements part of it or sends a PROPFIND:

  • Talk can't PROPFIND its attachments folder, it is too large. spreed/src/composables/useViewer.js:85 maps its own message data to a viewer file by hand: it converts the permission bitmask to the DAV string (CKGWDR) and derives hasPreview from preview-available === 'yes'.
  • Assistant sends one PROPFIND per file to get a Node for the Viewer (Properly build the node we pass to the Viewer assistant#675), which doesn't scale for huge folders or accounts with many shares.
  • Viewer builds 8 nodes by hand, Text 2.

It also costs performance. Fetching a few known files means a DAV SEARCH or one PROPFIND per file, through the full Sabre stack and the per-node prop handlers. While working on #2742 (live refresh from notify_push), a SEARCH for 10 file ids took 2.4 s on a test account with 5,000 incoming shares, mostly spent in searching all mounted storages and in per-node share lookups.

Describe the solution you'd like

One registry that both DAV and PHP read from.

  1. Apps register providers with the attributes they serve, in Clark notation like the DAV props, so the server only loads a provider when one of its attributes is requested:

    $context->registerNodeAttributeProvider(SharesAttributeProvider::class, [
        '{http://owncloud.org/ns}share-types',
        '{http://nextcloud.org/ns}sharees',
    ]);
    
    interface INodeAttributeProvider {
        /** Load what is needed for all the nodes of a response at once */
        public function preload(INodeAttributeContext $context, array $nodes, array $attributes): void;
        /** @return array<string, mixed> scalars, or NodeAttributeList for lists */
        public function getAttributes(INodeAttributeContext $context, Node $node, array $attributes): array;
    }
  2. A public serializer, INodeSerializer::serialize(INodeAttributeContext $context, array $nodes, ?array $attributes = null): array, returns the NodeData shape @nextcloud/files already uses, attributes keyed by local name like resultToNode() does today. Lists use the same shape the webdav client produces ({ 'share-type': [0, 3] }).

  3. A generic Sabre plugin serves registered attributes on propFind when no other plugin handled them, and calls preload() on preloadCollection and preloadProperties. Existing plugins keep working and move over one by one, and SEARCH results get batched loading for every migrated attribute.

  4. The registered attribute names reach the frontend through initial state, so @nextcloud/files requests them without registerDavProperty(). A counterpart to resultToNode(), for example dataToNode(), builds a Node from the serializer output.

  5. Public pages are a requirement, not a follow-up. Public share pages have no user, only a share token, so providers get an INodeAttributeContext holding either the user or the share, and the serializer and DAV adapter work the same on remote.php and public.php.

  6. Providers load everything in preload(), and getAttributes() doesn't query per node. A test helper asserts the same number of queries for 1 and 100 nodes, so the N+1 we see in SEARCH today (about 13 queries per node) doesn't come back.

  7. Rollout:

    • Phase 1: the registry, the serializer and the DAV adapter, with the five most used attributes: favorite, etag, has-preview, share-attributes and share-types. share-attributes is in on purpose, so the DAV/PHP parity tests for permission related attributes exist from the start.
    • Phase 2: the remaining server plugins.
    • Phase 3: props registered by apps (files_lock, Photos, Text).

Describe alternatives you've considered

  • Keep DAV as the only source and fetch with SEARCH or PROPFIND. That is what we do today, with the cost above.
  • Make formatFileInfo() the shared format. It doesn't know about plugin attributes, so the gap stays.

Additional context

What to migrate first. fileid, permissions, size, getlastmodified, getcontenttype, owner-id and displayname become top-level Node fields in resultToNode() and are needed by every view, so they are the core set. Among the other attributes, ranked by reads of node.attributes.* in server and in Photos, Text, Deck, Talk, Viewer, Office, Collectives, Activity, Mail, Recognize, files_lock, Forms and files_pdfviewer:

Attribute Reads Where Used for
favorite 37 files, files_sharing, photos star, favorites-first sorting, favorite action
etag 17 core, files, files_sharing, files_versions, photos, viewer preview cache-busting, change detection
has-preview 12 core, files, files_sharing, viewer, photos, mail; Talk maps its own thumbnails everywhere
share-attributes 12 files, files_sharing, files_versions, mail, richdocuments download restrictions on shared files
share-types 12 files, files_sharing, spreed, activity sharing icons and status
mount-type 9 files, files_sharing, files_versions, photos which actions apply to shared, external or groupfolder nodes
sharees 8 files_sharing "Shared with" avatars
owner-display-name 6 files, files_sharing owner of shared files
hide-download 3 files; Talk's backend sends its own ('yes'/'no') hiding downloads on restricted shares
is-mount-root 2 files rename, move and delete rules on mount roots
e2ee-is-encrypted 2 text, viewer end-to-end encrypted folders

Apps also register their own props with registerDavProperty(), and would become providers too: files_lock (nc:lock, nc:lock-owner, nc:lock-owner-displayname, nc:lock-owner-editor, nc:lock-owner-type, nc:lock-time), Photos (nc:metadata-photos-*, nc:metadata-blurhash, nc:metadata-files-live-photo), Text (nc:rich-workspace-flat, nc:rich-workspace-file-flat).

share-attributes, hide-download and share-permissions drive download and permission decisions, and Talk's backend already sends its own hide-download in message parameters (spreed/src/composables/useMessageInfo.ts:115). A mismatch between the DAV and PHP output there is a security issue, not a display bug, so they need tests comparing both outputs.

Open questions:

  • Attribute values: XML props are parsed by the webdav client into nested shapes (for example share-types becomes { 'share-type': [0, 3] }). The serializer either reproduces them, or both sides move to one clean shape, which touches every attribute consumer.
  • JSON keys are local names, as in resultToNode(), so two apps registering the same local name in different namespaces would collide. The registry would refuse the second one.
  • Permissions, share permissions and hide-download depend on the user's mount or on the public share, so the serializer has to work on user or share scoped nodes only, never on raw cache entries.
  • Existing registerDavProperty() apps need a transition period where both keep working.

Activity

  1. self-assigned this
    on Oct 11, 2026
  2. skjnldsv commented on Oct 11, 2026

    @skjnldsv
    MemberAuthor

    @icewind1991 @susnux @artonge @salmart-dev @cristianscheid @CarlSchwan: you know the filesystem, DAV, search and @nextcloud/files code this touches. A review of the proposal would help 🙏

    @julien-nc @Antreesy @juliusknorr @mejo-: Assistant, Talk, Text, files_lock, Deck and Collectives build file objects by hand or send a PROPFIND per file today. Your expertise is also highly appreciated 🙇

    I think allowing us to move away from webdav entirely for some specific use cases would be very effective and increase our performances. Like getting a specific file by its id without having to do a PROPFIND or a SEARCH

  3. susnux commented on Oct 11, 2026

    @susnux
    Contributor

    Note only, as not yet fully read:

    SEARCH for 10 file ids took 2.4 s

    Its likely far cheaper to instead do a PROPSTAT per file (usually only a low number of files change for a user).
    This does not need the server to do a full search but only need to setup the filesystem for that id (will is currently being improved performance-wise with partial share setup we work on for 36).

  4. skjnldsv commented on Oct 11, 2026

    @skjnldsv
    MemberAuthor

    @susnux right! I tried it on my heavy test account (70k files in the root, 5k incoming shares, MariaDB).
    A Depth 0 PROPFIND on a single file takes ~180 ms, and 10 of them in parallel ~630 ms. The SEARCH for the same 10 ids takes 2.4 s. So yes, way cheaper. Yet, even 180ms is a bit too much for my taste. And it's only one file on a not-so-busy test instance...

    There are two things I'm not sure how to handle yet:

    • notify_push only gives us ids. For files we already list that's fine, we know their path. But for a new file or a rename we only have the id, and there's no DAV route by id. So we'd still need some cheap lookup by id for those.
    • And for incoming share roots, Depth 0 and the folder listing don't return the same share-types/sharees. Depth 0 includes the incoming share, the listing doesn't. So refreshing a share root that way would change its sharing icon.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions