Completed
Pull Request — master (#6636)
by Damian
08:46
created

viewfile_getLocalFileByID()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 5
nc 2
nop 1
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Forms\HTMLEditor;
4
5
use SilverStripe\Assets\File;
6
use SilverStripe\Assets\Upload;
7
use SilverStripe\CMS\Model\SiteTree;
8
use SilverStripe\Control\Controller;
9
use SilverStripe\Control\Director;
10
use SilverStripe\Control\RequestHandler;
11
use SilverStripe\Control\HTTPRequest;
12
use SilverStripe\Control\HTTPResponse_Exception;
13
use SilverStripe\Forms\CheckboxField;
14
use SilverStripe\Forms\CompositeField;
15
use SilverStripe\Forms\EmailField;
16
use SilverStripe\Forms\FieldList;
17
use SilverStripe\Forms\Form;
18
use SilverStripe\Forms\GridField\GridField;
19
use SilverStripe\Forms\GridField\GridFieldConfig;
20
use SilverStripe\Forms\GridField\GridFieldDataColumns;
21
use SilverStripe\Forms\GridField\GridFieldDeleteAction;
22
use SilverStripe\Forms\GridField\GridFieldDetailForm;
23
use SilverStripe\Forms\GridField\GridFieldFilterHeader;
24
use SilverStripe\Forms\GridField\GridFieldPaginator;
25
use SilverStripe\Forms\GridField\GridFieldSortableHeader;
26
use SilverStripe\Forms\HiddenField;
27
use SilverStripe\Forms\LiteralField;
28
use SilverStripe\Forms\OptionsetField;
29
use SilverStripe\Forms\TextField;
30
use SilverStripe\Forms\TreeDropdownField;
31
use SilverStripe\Forms\UploadField;
32
use SilverStripe\ORM\DataList;
33
use SilverStripe\ORM\DataObject;
34
use SilverStripe\ORM\FieldType\DBField;
35
use SilverStripe\View\Requirements;
36
use SilverStripe\View\SSViewer;
37
38
/**
39
 * Toolbar shared by all instances of {@link HTMLEditorField}, to avoid too much markup duplication.
40
 *  Needs to be inserted manually into the template in order to function - see {@link LeftAndMain->EditorToolbar()}.
41
 */
42
class HTMLEditorField_Toolbar extends RequestHandler
43
{
44
45
    private static $allowed_actions = array(
46
        'LinkForm',
47
        'viewfile',
48
        'getanchors'
49
    );
50
51
    /**
52
     * @return string
53
     */
54
    public function getTemplateViewFile()
55
    {
56
        return SSViewer::get_templates_by_class(get_class($this), '_viewfile', __CLASS__);
57
    }
58
59
    /**
60
     * @var Controller
61
     */
62
    protected $controller;
63
64
    /**
65
     * @var string
66
     */
67
    protected $name;
68
69
    public function __construct($controller, $name)
70
    {
71
        parent::__construct();
72
73
        $this->controller = $controller;
74
        $this->name = $name;
75
    }
76
77
    public function forTemplate()
78
    {
79
        return sprintf(
80
            '<div id="cms-editor-dialogs" data-url-linkform="%s"></div>',
81
            Controller::join_links($this->controller->Link(), $this->name, 'LinkForm', 'forTemplate')
82
        );
83
    }
84
85
    /**
86
     * Searches the SiteTree for display in the dropdown
87
     *
88
     * @param string $sourceObject
89
     * @param string $labelField
90
     * @param string $search
91
     * @return DataList
92
     */
93
    public function siteTreeSearchCallback($sourceObject, $labelField, $search)
94
    {
95
        return DataObject::get($sourceObject)->filterAny(array(
96
            'MenuTitle:PartialMatch' => $search,
97
            'Title:PartialMatch' => $search
98
        ));
99
    }
100
101
    /**
102
     * Return a {@link Form} instance allowing a user to
103
     * add links in the TinyMCE content editor.
104
     *
105
     * @skipUpgrade
106
     * @return Form
107
     */
108
    public function LinkForm()
109
    {
110
        $siteTree = TreeDropdownField::create(
111
            'internal',
112
            _t('HTMLEditorField.PAGE', "Page"),
113
            SiteTree::class,
114
            'ID',
115
            'MenuTitle',
116
            true
117
        );
118
        // mimic the SiteTree::getMenuTitle(), which is bypassed when the search is performed
119
        $siteTree->setSearchFunction(array($this, 'siteTreeSearchCallback'));
120
121
        $numericLabelTmpl = '<span class="step-label"><span class="flyout">Step %d.</span>'
122
            . '<span class="title">%s</span></span>';
123
124
        $form = new Form(
125
            $this->controller,
126
            "{$this->name}/LinkForm",
127
            new FieldList(
128
                $headerWrap = new CompositeField(
129
                    new LiteralField(
130
                        'Heading',
131
                        sprintf(
132
                            '<h3 class="htmleditorfield-mediaform-heading insert">%s</h3>',
133
                            _t('HTMLEditorField.LINK', 'Insert Link')
134
                        )
135
                    )
136
                ),
137
                $contentComposite = new CompositeField(
138
                    OptionsetField::create(
139
                        'LinkType',
140
                        DBField::create_field(
141
                            'HTMLFragment',
142
                            sprintf($numericLabelTmpl, '1', _t('HTMLEditorField.LINKTO', 'Link type'))
143
                        ),
144
                        array(
145
                            'internal' => _t('HTMLEditorField.LINKINTERNAL', 'Link to a page on this site'),
146
                            'external' => _t('HTMLEditorField.LINKEXTERNAL', 'Link to another website'),
147
                            'anchor' => _t('HTMLEditorField.LINKANCHOR', 'Link to an anchor on this page'),
148
                            'email' => _t('HTMLEditorField.LINKEMAIL', 'Link to an email address'),
149
                            'file' => _t('HTMLEditorField.LINKFILE', 'Link to download a file'),
150
                        ),
151
                        'internal'
152
                    ),
153
                    LiteralField::create(
154
                        'Step2',
155
                        '<div class="step2">'
156
                        . sprintf($numericLabelTmpl, '2', _t('HTMLEditorField.LINKDETAILS', 'Link details')) . '</div>'
157
                    ),
158
                    $siteTree,
159
                    TextField::create('external', _t('HTMLEditorField.URL', 'URL'), 'http://'),
160
                    EmailField::create('email', _t('HTMLEditorField.EMAIL', 'Email address')),
161
                    $fileField = TreeDropdownField::create(
162
                        'file',
163
                        _t('HTMLEditorField.FILE', 'File'),
164
                        File::class,
165
                        'ID',
166
                        'Name'
167
                    ),
168
                    TextField::create('Anchor', _t('HTMLEditorField.ANCHORVALUE', 'Anchor')),
169
                    TextField::create('Subject', _t('HTMLEditorField.SUBJECT', 'Email subject')),
170
                    TextField::create('Description', _t('HTMLEditorField.LINKDESCR', 'Link description')),
171
                    CheckboxField::create(
172
                        'TargetBlank',
173
                        _t('HTMLEditorField.LINKOPENNEWWIN', 'Open link in a new window?')
174
                    ),
175
                    HiddenField::create('Locale', null, $this->controller->Locale)
176
                )
177
            ),
178
            new FieldList()
179
        );
180
181
        $headerWrap->setName('HeaderWrap');
182
        $headerWrap->addExtraClass('CompositeField composite cms-content-header form-group--no-label ');
183
        $contentComposite->setName('ContentBody');
184
        $contentComposite->addExtraClass('ss-insert-link content');
185
186
        $form->unsetValidator();
187
        $form->loadDataFrom($this);
188
        $form->addExtraClass('htmleditorfield-form htmleditorfield-linkform cms-mediaform-content');
189
190
        $this->extend('updateLinkForm', $form);
191
192
        return $form;
193
    }
194
195
    /**
196
     * Get the folder ID to filter files by for the "from cms" tab
197
     *
198
     * @return int
199
     */
200
    protected function getAttachParentID()
201
    {
202
        $parentID = $this->controller->getRequest()->requestVar('ParentID');
203
        $this->extend('updateAttachParentID', $parentID);
204
        return $parentID;
205
    }
206
207
    /**
208
     * List of allowed schemes (no wildcard, all lower case) or empty to allow all schemes
209
     *
210
     * @config
211
     * @var array
212
     */
213
    private static $fileurl_scheme_whitelist = array('http', 'https');
214
215
    /**
216
     * List of allowed domains (no wildcard, all lower case) or empty to allow all domains
217
     *
218
     * @config
219
     * @var array
220
     */
221
    private static $fileurl_domain_whitelist = array();
222
223
    /**
224
     * Find local File dataobject given ID
225
     *
226
     * @param int $id
227
     * @return array
228
     */
229
    protected function viewfile_getLocalFileByID($id)
230
    {
231
        /** @var File $file */
232
        $file = DataObject::get_by_id('SilverStripe\\Assets\\File', $id);
233
        if ($file && $file->canView()) {
234
            return array($file, $file->getURL());
235
        }
236
        return [null, null];
237
    }
238
239
    /**
240
     * Get remote File given url
241
     *
242
     * @param string $fileUrl Absolute URL
243
     * @return array
244
     * @throws HTTPResponse_Exception
245
     */
246
    protected function viewfile_getRemoteFileByURL($fileUrl)
247
    {
248
        if (!Director::is_absolute_url($fileUrl)) {
249
            throw $this->getErrorFor(_t(
250
                "HTMLEditorField_Toolbar.ERROR_ABSOLUTE",
251
                "Only absolute urls can be embedded"
252
            ));
253
        }
254
        $scheme = strtolower(parse_url($fileUrl, PHP_URL_SCHEME));
255
        $allowed_schemes = self::config()->fileurl_scheme_whitelist;
0 ignored issues
show
Documentation introduced by
The property fileurl_scheme_whitelist does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
256
        if (!$scheme || ($allowed_schemes && !in_array($scheme, $allowed_schemes))) {
257
            throw $this->getErrorFor(_t(
258
                "HTMLEditorField_Toolbar.ERROR_SCHEME",
259
                "This file scheme is not included in the whitelist"
260
            ));
261
        }
262
        $domain = strtolower(parse_url($fileUrl, PHP_URL_HOST));
263
        $allowed_domains = self::config()->fileurl_domain_whitelist;
0 ignored issues
show
Documentation introduced by
The property fileurl_domain_whitelist does not exist on object<SilverStripe\Core\Config\Config_ForClass>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
264
        if (!$domain || ($allowed_domains && !in_array($domain, $allowed_domains))) {
265
            throw $this->getErrorFor(_t(
266
                "HTMLEditorField_Toolbar.ERROR_HOSTNAME",
267
                "This file hostname is not included in the whitelist"
268
            ));
269
        }
270
        return [null, $fileUrl];
271
    }
272
273
    /**
274
     * Prepare error for the front end
275
     *
276
     * @param string $message
277
     * @param int $code
278
     * @return HTTPResponse_Exception
279
     */
280
    protected function getErrorFor($message, $code = 400)
281
    {
282
        $exception = new HTTPResponse_Exception($message, $code);
283
        $exception->getResponse()->addHeader('X-Status', $message);
284
        return $exception;
285
    }
286
287
    /**
288
     * View of a single file, either on the filesystem or on the web.
289
     *
290
     * @throws HTTPResponse_Exception
291
     * @param HTTPRequest $request
292
     * @return string
293
     */
294
    public function viewfile($request)
295
    {
296
        $file = null;
297
        $url = null;
298
        // Get file and url by request method
299
        if ($fileUrl = $request->getVar('FileURL')) {
300
            // Get remote url
301
            list($file, $url) = $this->viewfile_getRemoteFileByURL($fileUrl);
302
        } elseif ($id = $request->getVar('ID')) {
303
            // Or we could have been passed an ID directly
304
            list($file, $url) = $this->viewfile_getLocalFileByID($id);
305
        } else {
306
            // Or we could have been passed nothing, in which case panic
307
            throw $this->getErrorFor(_t(
308
                "HTMLEditorField_Toolbar.ERROR_ID",
309
                'Need either "ID" or "FileURL" parameter to identify the file'
310
            ));
311
        }
312
313
        // Validate file exists
314
        if (!$url) {
315
            throw $this->getErrorFor(_t(
316
                "HTMLEditorField_Toolbar.ERROR_NOTFOUND",
317
                'Unable to find file to view'
318
            ));
319
        }
320
321
        // Instantiate file wrapper and get fields based on its type
322
        // Check if appCategory is an image and exists on the local system, otherwise use Embed to reference a
323
        // remote image
324
        $fileCategory = $this->getFileCategory($url, $file);
325
        switch ($fileCategory) {
326
            case 'image':
327
            case 'image/supported':
328
                $fileWrapper = new HTMLEditorField_Image($url, $file);
329
                break;
330
            case 'flash':
331
                $fileWrapper = new HTMLEditorField_Flash($url, $file);
332
                break;
333
            default:
334
                // Only remote files can be linked via o-embed
335
                // {@see HTMLEditorField_Toolbar::getAllowedExtensions())
0 ignored issues
show
Unused Code Comprehensibility introduced by
50% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
336
                if ($file) {
337
                    throw $this->getErrorFor(_t(
338
                        "HTMLEditorField_Toolbar.ERROR_OEMBED_REMOTE",
339
                        "Embed is only compatible with remote files"
340
                    ));
341
                }
342
343
                // Other files should fallback to embed
344
                $fileWrapper = new HTMLEditorField_Embed($url, $file);
345
                break;
346
        }
347
348
        // Render fields and return
349
        $fields = $this->getFieldsForFile($url, $fileWrapper);
350
        return $fileWrapper->customise(array(
351
            'Fields' => $fields,
352
        ))->renderWith($this->getTemplateViewFile());
353
    }
354
355
    /**
356
     * Guess file category from either a file or url
357
     *
358
     * @param string $url
359
     * @param File $file
360
     * @return string
361
     */
362
    protected function getFileCategory($url, $file)
363
    {
364
        if ($file) {
365
            return $file->appCategory();
366
        }
367
        if ($url) {
368
            return File::get_app_category(File::get_file_extension($url));
369
        }
370
        return null;
371
    }
372
373
    /**
374
     * Find all anchors available on the given page.
375
     *
376
     * @return array
377
     * @throws HTTPResponse_Exception
378
     */
379
    public function getanchors()
380
    {
381
        $id = (int)$this->getRequest()->getVar('PageID');
382
        $anchors = array();
383
384
        if (($page = SiteTree::get()->byID($id)) && !empty($page)) {
385
            if (!$page->canView()) {
386
                throw new HTTPResponse_Exception(
387
                    _t(
388
                        'HTMLEditorField.ANCHORSCANNOTACCESSPAGE',
389
                        'You are not permitted to access the content of the target page.'
390
                    ),
391
                    403
392
                );
393
            }
394
395
            // Parse the shortcodes so [img id=x] doesn't end up as anchor x
396
            $htmlValue = $page->obj('Content')->forTemplate();
397
398
            // Similar to the regex found in HTMLEditorField.js / getAnchors method.
399
            if (preg_match_all(
400
                "/\\s+(name|id)\\s*=\\s*([\"'])([^\\2\\s>]*?)\\2|\\s+(name|id)\\s*=\\s*([^\"']+)[\\s +>]/im",
401
                $htmlValue,
402
                $matches
403
            )) {
404
                $anchors = array_values(array_unique(array_filter(
405
                    array_merge($matches[3], $matches[5])
406
                )));
407
            }
408
        } else {
409
            throw new HTTPResponse_Exception(
410
                _t('HTMLEditorField.ANCHORSPAGENOTFOUND', 'Target page not found.'),
411
                404
412
            );
413
        }
414
415
        return json_encode($anchors);
416
    }
417
418
    /**
419
     * Similar to {@link File->getCMSFields()}, but only returns fields
420
     * for manipulating the instance of the file as inserted into the HTML content,
421
     * not the "master record" in the database - hence there's no form or saving logic.
422
     *
423
     * @param string $url Abolute URL to asset
424
     * @param HTMLEditorField_File $file Asset wrapper
425
     * @return FieldList
426
     */
427
    protected function getFieldsForFile($url, HTMLEditorField_File $file)
428
    {
429
        $fields = $this->extend('getFieldsForFile', $url, $file);
430
        if (!$fields) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $fields of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
431
            $fields = $file->getFields();
432
            $file->extend('updateFields', $fields);
433
        }
434
        $this->extend('updateFieldsForFile', $fields, $url, $file);
435
        return $fields;
436
    }
437
438
439
    /**
440
     * Gets files filtered by a given parent with the allowed extensions
441
     *
442
     * @param int $parentID
443
     * @return DataList
444
     */
445
    protected function getFiles($parentID = null)
446
    {
447
        $exts = $this->getAllowedExtensions();
448
        $dotExts = array_map(function ($ext) {
449
            return ".{$ext}";
450
        }, $exts);
451
        $files = File::get()->filter('Name:EndsWith', $dotExts);
452
453
        // Limit by folder (if required)
454
        if ($parentID) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $parentID of type integer|null is loosely compared to true; this is ambiguous if the integer can be zero. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
455
            $files = $files->filter('ParentID', $parentID);
456
        }
457
458
        return $files;
459
    }
460
461
    /**
462
     * @return array All extensions which can be handled by the different views.
463
     */
464
    protected function getAllowedExtensions()
465
    {
466
        $exts = array('jpg', 'gif', 'png', 'swf', 'jpeg');
467
        $this->extend('updateAllowedExtensions', $exts);
468
        return $exts;
469
    }
470
}
471