33declare (strict_types=1 );
44
55/**
6- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
6+ * SPDX-FileCopyrightText: 2019-2025 Nextcloud GmbH and Nextcloud contributors
77 * SPDX-License-Identifier: AGPL-3.0-or-later
88 */
99namespace OCA \Files_Versions \Sabre ;
2121use Sabre \HTTP \RequestInterface ;
2222use Sabre \HTTP \ResponseInterface ;
2323
24+ /**
25+ * SabreDAV plugin for managing versioned file access and metadata.
26+ *
27+ * Handles WebDAV requests related to file versions, including download headers,
28+ * version metadata properties, and compatibility for various clients and browsers.
29+ */
2430class Plugin extends ServerPlugin {
25- private Server $ server ;
26-
2731 public const LABEL = 'label ' ;
28-
2932 public const AUTHOR = 'author ' ;
30-
3133 public const VERSION_LABEL = '{http://nextcloud.org/ns}version-label ' ;
32-
33- public const VERSION_AUTHOR = '{http://nextcloud.org/ns}version-author ' ; // dav property for author
34+ public const VERSION_AUTHOR = '{http://nextcloud.org/ns}version-author ' ;
35+ private const LEGACY_FILENAME_HEADER_USER_AGENTS = [ // Quirky clients
36+ Request::USER_AGENT_IE ,
37+ Request::USER_AGENT_ANDROID_MOBILE_CHROME ,
38+ Request::USER_AGENT_FREEBOX ,
39+ ];
40+ private Server $ server ;
3441
3542 public function __construct (
36- private IRequest $ request ,
37- private IPreview $ previewManager ,
43+ private readonly IRequest $ request ,
44+ private readonly IPreview $ previewManager ,
3845 ) {
39- $ this ->request = $ request ;
4046 }
4147
42- public function initialize (Server $ server ) {
48+ public function initialize (Server $ server ): void {
4349 $ this ->server = $ server ;
4450
4551 $ server ->on ('afterMethod:GET ' , [$ this , 'afterGet ' ]);
4652 $ server ->on ('propFind ' , [$ this , 'propFind ' ]);
4753 $ server ->on ('propPatch ' , [$ this , 'propPatch ' ]);
4854 }
4955
50- public function afterGet (RequestInterface $ request , ResponseInterface $ response ) {
56+ /**
57+ * Handles the GET request for versioned files.
58+ *
59+ * Validates the request path, checks node type, and sets appropriate download headers
60+ * to ensure compatibility across different clients and browsers.
61+ */
62+ public function afterGet (RequestInterface $ request , ResponseInterface $ response ): void {
5163 $ path = $ request ->getPath ();
52- if (!str_starts_with ($ path , 'versions ' )) {
64+
65+ if (!str_starts_with ($ path , 'versions/ ' )) {
5366 return ;
5467 }
5568
@@ -64,36 +77,84 @@ public function afterGet(RequestInterface $request, ResponseInterface $response)
6477 }
6578
6679 $ filename = $ node ->getVersion ()->getSourceFileName ();
67-
68- if ($ this ->request ->isUserAgent (
69- [
70- Request::USER_AGENT_IE ,
71- Request::USER_AGENT_ANDROID_MOBILE_CHROME ,
72- Request::USER_AGENT_FREEBOX ,
73- ])) {
74- $ response ->addHeader ('Content-Disposition ' , 'attachment; filename=" ' . rawurlencode ($ filename ) . '" ' );
75- } else {
76- $ response ->addHeader ('Content-Disposition ' , 'attachment; filename*=UTF-8 \'\'' . rawurlencode ($ filename )
77- . '; filename=" ' . rawurlencode ($ filename ) . '" ' );
78- }
80+ $ this ->addContentDispositionHeader ($ response , $ filename );
7981 }
8082
83+ /**
84+ * WebDAV PROPFIND event handler for versioned files.
85+ *
86+ * Provides read-only access to version-related information, upon request, if the
87+ * current node is a VersionFile.
88+ */
8189 public function propFind (PropFind $ propFind , INode $ node ): void {
82- if ($ node instanceof VersionFile) {
83- $ propFind ->handle (self ::VERSION_LABEL , fn () => $ node ->getMetadataValue (self ::LABEL ));
84- $ propFind ->handle (self ::VERSION_AUTHOR , fn () => $ node ->getMetadataValue (self ::AUTHOR ));
85- $ propFind ->handle (
86- FilesPlugin::HAS_PREVIEW_PROPERTYNAME ,
87- fn (): string => $ this ->previewManager ->isMimeSupported ($ node ->getContentType ()) ? 'true ' : 'false ' ,
88- );
90+ if (!($ node instanceof VersionFile)) {
91+ return ;
8992 }
93+
94+ $ propFind ->handle (
95+ self ::VERSION_LABEL ,
96+ fn () => $ node ->getMetadataValue (self ::LABEL )
97+ );
98+ $ propFind ->handle (
99+ self ::VERSION_AUTHOR ,
100+ fn () => $ node ->getMetadataValue (self ::AUTHOR )
101+ );
102+ $ propFind ->handle (
103+ FilesPlugin::HAS_PREVIEW_PROPERTYNAME ,
104+ fn (): string => $ this ->previewManager ->isMimeSupported ($ node ->getContentType ()) ? 'true ' : 'false ' ,
105+ );
90106 }
91107
92- public function propPatch ($ path , PropPatch $ propPatch ): void {
108+ /**
109+ * WebDAV PROPPATCH event handler for versioned files.
110+ *
111+ * Updates version related properties (such as the label) on VersionFile nodes.
112+ */
113+ public function propPatch (string $ path , PropPatch $ propPatch ): void {
93114 $ node = $ this ->server ->tree ->getNodeForPath ($ path );
94115
95- if ($ node instanceof VersionFile) {
96- $ propPatch -> handle ( self :: VERSION_LABEL , fn ( string $ label ) => $ node -> setMetadataValue ( self :: LABEL , $ label )) ;
116+ if (!( $ node instanceof VersionFile) ) {
117+ return ;
97118 }
119+
120+ $ propPatch ->handle (
121+ self ::VERSION_LABEL ,
122+ fn (string $ label ) => $ node ->setMetadataValue (self ::LABEL , $ label )
123+ );
124+ }
125+
126+ /**
127+ * Add a Content-Disposition header in a way that attempts to be broadly compatible with various user agents.
128+ *
129+ * Sends both 'filename' (legacy quoted) and 'filename*' (UTF-8 encoded) per RFC 6266,
130+ * except for known quirky agents known to mishandle the `filename*`, which only get `filename`.
131+ *
132+ * Note: The quoting/escaping should strictly follow RFC 6266 and RFC 5987.
133+ *
134+ * TODO: Currently uses rawurlencode($filename) for both parameters, which is wrong: filename= should be plain
135+ * quoted ASCII (with necessary escaping), while filename* should be UTF-8 percent-encoded.
136+ * TODO: This logic appears elsewhere (sometimes with different quoting/filename handling) and could benefit
137+ * from a shared utility function. See Symfony example:
138+ * - https://github.com/symfony/symfony/blob/175775eb21508becf7e7a16d65959488e522c39a/src/Symfony/Component/HttpFoundation/BinaryFileResponse.php#L146-L155
139+ * - https://github.com/symfony/symfony/blob/175775eb21508becf7e7a16d65959488e522c39a/src/Symfony/Component/HttpFoundation/HeaderUtils.php#L152-L165
140+ *
141+ * @param ResponseInterface $response HTTP response object to add the header to
142+ * @param string $filename Download filename
143+ */
144+ private function addContentDispositionHeader (ResponseInterface $ response , string $ filename ): void {
145+ if (!$ this ->request ->isUserAgent (self ::LEGACY_FILENAME_HEADER_USER_AGENTS )) {
146+ // Modern clients will use 'filename*'; older clients will refer to `filename`.
147+ // The older fallback must be listed first per RFC.
148+ // In theory this is all we actually need to handle both client types.
149+ $ response ->addHeader (
150+ 'Content-Disposition ' ,
151+ 'attachment; filename=" ' . rawurlencode ($ filename ) . '"; filename*=UTF-8 \'\'' . rawurlencode ($ filename )
152+ );
153+ } else {
154+ // Quirky clients that choke on `filename*`: only send `filename=`
155+ $ response ->addHeader (
156+ 'Content-Disposition ' ,
157+ 'attachment; filename=" ' . rawurlencode ($ filename ) . '" ' );
158+ }
98159 }
99160}
0 commit comments