Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.27% |
107 / 110 |
|
50.00% |
1 / 2 |
CRAP | |
0.00% |
0 / 1 |
AuthenticatedFileEntryPoint | |
97.27% |
107 / 110 |
|
50.00% |
1 / 2 |
31 | |
0.00% |
0 / 1 |
execute | |
96.43% |
81 / 84 |
|
0.00% |
0 / 1 |
27 | |||
forbidden | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
4 |
1 | <?php |
2 | /** |
3 | * Entry point implementation for serving non-public images to logged-in users. |
4 | * |
5 | * @see /img_auth.php The web entry point. |
6 | * |
7 | * This program is free software; you can redistribute it and/or modify |
8 | * it under the terms of the GNU General Public License as published by |
9 | * the Free Software Foundation; either version 2 of the License, or |
10 | * (at your option) any later version. |
11 | * |
12 | * This program is distributed in the hope that it will be useful, |
13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
15 | * GNU General Public License for more details. |
16 | * |
17 | * You should have received a copy of the GNU General Public License along |
18 | * with this program; if not, write to the Free Software Foundation, Inc., |
19 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
20 | * http://www.gnu.org/copyleft/gpl.html |
21 | * |
22 | * @file |
23 | * @ingroup entrypoint |
24 | */ |
25 | |
26 | namespace MediaWiki\FileRepo; |
27 | |
28 | use File; |
29 | use MediaWiki\HookContainer\HookRunner; |
30 | use MediaWiki\Html\TemplateParser; |
31 | use MediaWiki\MainConfigNames; |
32 | use MediaWiki\MediaWikiEntryPoint; |
33 | use MediaWiki\Title\Title; |
34 | use Wikimedia\FileBackend\HTTPFileStreamer; |
35 | use Wikimedia\Message\MessageParam; |
36 | use Wikimedia\Message\MessageSpecifier; |
37 | |
38 | class AuthenticatedFileEntryPoint extends MediaWikiEntryPoint { |
39 | |
40 | /** |
41 | * Main entry point |
42 | */ |
43 | public function execute() { |
44 | $services = $this->getServiceContainer(); |
45 | $permissionManager = $services->getPermissionManager(); |
46 | |
47 | $request = $this->getRequest(); |
48 | $publicWiki = $services->getGroupPermissionsLookup()->groupHasPermission( '*', 'read' ); |
49 | |
50 | // Find the path assuming the request URL is relative to the local public zone URL |
51 | $baseUrl = $services->getRepoGroup()->getLocalRepo()->getZoneUrl( 'public' ); |
52 | if ( $baseUrl[0] === '/' ) { |
53 | $basePath = $baseUrl; |
54 | } else { |
55 | $basePath = parse_url( $baseUrl, PHP_URL_PATH ); |
56 | } |
57 | $path = $this->getRequestPathSuffix( "$basePath" ); |
58 | |
59 | if ( $path === false ) { |
60 | // Try instead assuming img_auth.php is the base path |
61 | $basePath = $this->getConfig( MainConfigNames::ImgAuthPath ) |
62 | ?: $this->getConfig( MainConfigNames::ScriptPath ) . '/img_auth.php'; |
63 | $path = $this->getRequestPathSuffix( $basePath ); |
64 | } |
65 | |
66 | if ( $path === false ) { |
67 | $this->forbidden( 'img-auth-accessdenied', 'img-auth-notindir' ); |
68 | return; |
69 | } |
70 | |
71 | if ( $path === '' || $path[0] !== '/' ) { |
72 | // Make sure $path has a leading / |
73 | $path = "/" . $path; |
74 | } |
75 | |
76 | $user = $this->getContext()->getUser(); |
77 | |
78 | // Various extensions may have their own backends that need access. |
79 | // Check if there is a special backend and storage base path for this file. |
80 | $pathMap = $this->getConfig( MainConfigNames::ImgAuthUrlPathMap ); |
81 | foreach ( $pathMap as $prefix => $storageDir ) { |
82 | $prefix = rtrim( $prefix, '/' ) . '/'; // implicit trailing slash |
83 | if ( strpos( $path, $prefix ) === 0 ) { |
84 | $be = $services->getFileBackendGroup()->backendFromPath( $storageDir ); |
85 | $filename = $storageDir . substr( $path, strlen( $prefix ) ); // strip prefix |
86 | // Check basic user authorization |
87 | $isAllowedUser = $permissionManager->userHasRight( $user, 'read' ); |
88 | if ( !$isAllowedUser ) { |
89 | $this->forbidden( 'img-auth-accessdenied', 'img-auth-noread', $path ); |
90 | return; |
91 | } |
92 | if ( $be && $be->fileExists( [ 'src' => $filename ] ) ) { |
93 | wfDebugLog( 'img_auth', "Streaming `" . $filename . "`." ); |
94 | $be->streamFile( [ |
95 | 'src' => $filename, |
96 | 'headers' => [ 'Cache-Control: private', 'Vary: Cookie' ] |
97 | ] ); |
98 | } else { |
99 | $this->forbidden( 'img-auth-accessdenied', 'img-auth-nofile', $path ); |
100 | } |
101 | |
102 | return; |
103 | } |
104 | } |
105 | |
106 | // Get the local file repository |
107 | $repo = $services->getRepoGroup()->getLocalRepo(); |
108 | $zone = strstr( ltrim( $path, '/' ), '/', true ); |
109 | |
110 | // Get the full file storage path and extract the source file name. |
111 | // (e.g. 120px-Foo.png => Foo.png or page2-120px-Foo.png => Foo.png). |
112 | // This only applies to thumbnails/transcoded, and each of them should |
113 | // be under a folder that has the source file name. |
114 | if ( $zone === 'thumb' || $zone === 'transcoded' ) { |
115 | $name = wfBaseName( dirname( $path ) ); |
116 | $filename = $repo->getZonePath( $zone ) . substr( $path, strlen( "/" . $zone ) ); |
117 | // Check to see if the file exists |
118 | if ( !$repo->fileExists( $filename ) ) { |
119 | $this->forbidden( 'img-auth-accessdenied', 'img-auth-nofile', $filename ); |
120 | return; |
121 | } |
122 | } else { |
123 | $name = wfBaseName( $path ); // file is a source file |
124 | $filename = $repo->getZonePath( 'public' ) . $path; |
125 | // Check to see if the file exists and is not deleted |
126 | $bits = explode( '!', $name, 2 ); |
127 | if ( str_starts_with( $path, '/archive/' ) && count( $bits ) == 2 ) { |
128 | $file = $repo->newFromArchiveName( $bits[1], $name ); |
129 | } else { |
130 | $file = $repo->newFile( $name ); |
131 | } |
132 | if ( !$file || !$file->exists() || $file->isDeleted( File::DELETED_FILE ) ) { |
133 | $this->forbidden( 'img-auth-accessdenied', 'img-auth-nofile', $filename ); |
134 | return; |
135 | } |
136 | } |
137 | |
138 | $headers = []; // extra HTTP headers to send |
139 | |
140 | $title = Title::makeTitleSafe( NS_FILE, $name ); |
141 | |
142 | $hookRunner = new HookRunner( $services->getHookContainer() ); |
143 | if ( !$publicWiki ) { |
144 | // For private wikis, run extra auth checks and set cache control headers |
145 | $headers['Cache-Control'] = 'private'; |
146 | $headers['Vary'] = 'Cookie'; |
147 | |
148 | if ( !$title instanceof Title ) { // files have valid titles |
149 | $this->forbidden( 'img-auth-accessdenied', 'img-auth-badtitle', $name ); |
150 | return; |
151 | } |
152 | |
153 | // Run hook for extension authorization plugins |
154 | $authResult = []; |
155 | if ( !$hookRunner->onImgAuthBeforeStream( $title, $path, $name, $authResult ) ) { |
156 | $this->forbidden( $authResult[0], $authResult[1], array_slice( $authResult, 2 ) ); |
157 | return; |
158 | } |
159 | |
160 | // Check user authorization for this title |
161 | // Checks Whitelist too |
162 | |
163 | if ( !$permissionManager->userCan( 'read', $user, $title ) ) { |
164 | $this->forbidden( 'img-auth-accessdenied', 'img-auth-noread', $name ); |
165 | return; |
166 | } |
167 | } |
168 | |
169 | $range = $this->environment->getServerInfo( 'HTTP_RANGE' ); |
170 | $ims = $this->environment->getServerInfo( 'HTTP_IF_MODIFIED_SINCE' ); |
171 | |
172 | if ( $range !== null ) { |
173 | $headers['Range'] = $range; |
174 | } |
175 | if ( $ims !== null ) { |
176 | $headers['If-Modified-Since'] = $ims; |
177 | } |
178 | |
179 | if ( $request->getCheck( 'download' ) ) { |
180 | $headers['Content-Disposition'] = 'attachment'; |
181 | } |
182 | |
183 | // Allow modification of headers before streaming a file |
184 | $hookRunner->onImgAuthModifyHeaders( $title->getTitleValue(), $headers ); |
185 | |
186 | // Stream the requested file |
187 | $this->prepareForOutput(); |
188 | |
189 | [ $headers, $options ] = HTTPFileStreamer::preprocessHeaders( $headers ); |
190 | wfDebugLog( 'img_auth', "Streaming `" . $filename . "`." ); |
191 | $repo->streamFileWithStatus( $filename, $headers, $options ); |
192 | |
193 | $this->enterPostSendMode(); |
194 | } |
195 | |
196 | /** |
197 | * Issue a standard HTTP 403 Forbidden header ($msg1-a message index, not a message) and an |
198 | * error message ($msg2, also a message index), (both required) then end the script |
199 | * subsequent arguments to $msg2 will be passed as parameters only for replacing in $msg2 |
200 | * |
201 | * @param string $msg1 |
202 | * @param string $msg2 |
203 | * @phpcs:ignore Generic.Files.LineLength |
204 | * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$args |
205 | * See Message::params() |
206 | */ |
207 | private function forbidden( $msg1, $msg2, ...$args ) { |
208 | $args = ( isset( $args[0] ) && is_array( $args[0] ) ) ? $args[0] : $args; |
209 | $context = $this->getContext(); |
210 | |
211 | $msgHdr = $context->msg( $msg1 )->text(); |
212 | $detailMsg = $this->getConfig( MainConfigNames::ImgAuthDetails ) |
213 | ? $context->msg( $msg2, $args )->text() |
214 | : $context->msg( 'badaccess-group0' )->text(); |
215 | |
216 | wfDebugLog( |
217 | 'img_auth', |
218 | "wfForbidden Hdr: " . $context->msg( $msg1 )->inLanguage( 'en' )->text() |
219 | . " Msg: " . $context->msg( $msg2, $args )->inLanguage( 'en' )->text() |
220 | ); |
221 | |
222 | $this->status( 403 ); |
223 | $this->header( 'Cache-Control: no-cache' ); |
224 | $this->header( 'Content-Type: text/html; charset=utf-8' ); |
225 | $language = $context->getLanguage(); |
226 | $lang = $language->getHtmlCode(); |
227 | $this->header( "Content-Language: $lang" ); |
228 | $templateParser = new TemplateParser(); |
229 | $this->print( |
230 | $templateParser->processTemplate( 'ImageAuthForbidden', [ |
231 | 'dir' => $language->getDir(), |
232 | 'lang' => $lang, |
233 | 'msgHdr' => $msgHdr, |
234 | 'detailMsg' => $detailMsg, |
235 | ] ) |
236 | ); |
237 | } |
238 | } |