Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.27% covered (success)
97.27%
107 / 110
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthenticatedFileEntryPoint
97.27% covered (success)
97.27%
107 / 110
50.00% covered (danger)
50.00%
1 / 2
31
0.00% covered (danger)
0.00%
0 / 1
 execute
96.43% covered (success)
96.43%
81 / 84
0.00% covered (danger)
0.00%
0 / 1
27
 forbidden
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
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
26namespace MediaWiki\FileRepo;
27
28use File;
29use MediaWiki\HookContainer\HookRunner;
30use MediaWiki\Html\TemplateParser;
31use MediaWiki\MainConfigNames;
32use MediaWiki\MediaWikiEntryPoint;
33use MediaWiki\Title\Title;
34use Wikimedia\FileBackend\HTTPFileStreamer;
35use Wikimedia\Message\MessageParam;
36use Wikimedia\Message\MessageSpecifier;
37
38class 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}