Completed
Push — pathconf ( 6b4a1f...acbd18 )
by Felipe A.
30s
created

Directory.choose_filename()   F

Complexity

Conditions 9

Size

Total Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 34
rs 3
cc 9
1
#!/usr/bin/env python
2
# -*- coding: UTF-8 -*-
3
4
import os
5
import os.path
6
import re
7
import shutil
8
import codecs
9
import string
10
import random
11
import datetime
12
import logging
13
14
from flask import current_app, send_from_directory
15
from werkzeug.utils import cached_property
16
17
from . import compat
18
from .compat import range
19
from .stream import TarFileStream
20
from .exceptions import OutsideDirectoryBase, OutsideRemovableBase, \
21
                        PathTooLongError, FilenameTooLongError
22
23
24
logger = logging.getLogger(__name__)
25
unicode_underscore = '_'.decode('utf-8') if compat.PY_LEGACY else '_'
26
underscore_replace = '%s:underscore' % __name__
27
codecs.register_error(underscore_replace,
28
                      lambda error: (unicode_underscore, error.start + 1)
29
                      )
30
binary_units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB")
31
standard_units = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
32
common_path_separators = '\\/'
33
restricted_chars = '/\0'
34
nt_restricted_chars = '/\0\\<>:"|?*' + ''.join(map(chr, range(1, 32)))
35
restricted_names = ('.', '..', '::', '/', '\\')
36
nt_device_names = (
37
    ('CON', 'PRN', 'AUX', 'NUL') +
38
    tuple(map('COM{}'.format, range(1, 10))) +
39
    tuple(map('LPT{}'.format, range(1, 10)))
40
    )
41
fs_safe_characters = string.ascii_uppercase + string.digits
42
43
44
class Node(object):
45
    '''
46
    Abstract filesystem node class.
47
48
    This represents an unspecified entity with a filesystem's path suitable for
49
    being inherited by plugins.
50
51
    When inheriting, the following attributes should be overwritten in order
52
    to specify :meth:`from_urlpath` classmethod behavior:
53
54
    * :attr:`generic`, if true, an instance of directory_class or file_class
55
      will be created instead of an instance of this class tself.
56
    * :attr:`directory_class`, class will be used for directory nodes,
57
    * :attr:`file_class`, class will be used for file nodes.
58
    '''
59
    generic = True
60
    directory_class = None  # set later at import time
61
    file_class = None  # set later at import time
62
63
    re_charset = re.compile('; charset=(?P<charset>[^;]+)')
64
    can_download = False
65
    is_root = False
66
67
    @cached_property
68
    def is_excluded(self):
69
        '''
70
        Get if current node shouldn't be shown, using :attt:`app` config's
71
        exclude_fnc.
72
73
        :returns: True if excluded, False otherwise
74
        '''
75
        exclude = self.app and self.app.config['exclude_fnc']
76
        return exclude and exclude(self.path)
77
78
    @cached_property
79
    def plugin_manager(self):
80
        '''
81
        Get current app's plugin manager.
82
83
        :returns: plugin manager instance
84
        '''
85
        return self.app.extensions['plugin_manager']
86
87
    @cached_property
88
    def widgets(self):
89
        '''
90
        List widgets with filter return True for this node (or without filter).
91
92
        Remove button is prepended if :property:can_remove returns true.
93
94
        :returns: list of widgets
95
        :rtype: list of namedtuple instances
96
        '''
97
        widgets = []
98
        if self.can_remove:
99
            widgets.append(
100
                self.plugin_manager.create_widget(
101
                    'entry-actions',
102
                    'button',
103
                    file=self,
104
                    css='remove',
105
                    endpoint='remove'
106
                    )
107
                )
108
        return widgets + self.plugin_manager.get_widgets(file=self)
109
110
    @cached_property
111
    def link(self):
112
        '''
113
        Get last widget with place "entry-link".
114
115
        :returns: widget on entry-link (ideally a link one)
116
        :rtype: namedtuple instance
117
        '''
118
        link = None
119
        for widget in self.widgets:
120
            if widget.place == 'entry-link':
121
                link = widget
122
        return link
123
124
    @cached_property
125
    def can_remove(self):
126
        '''
127
        Get if current node can be removed based on app config's
128
        directory_remove.
129
130
        :returns: True if current node can be removed, False otherwise.
131
        :rtype: bool
132
        '''
133
        dirbase = self.app.config["directory_remove"]
134
        return bool(dirbase and check_under_base(self.path, dirbase))
135
136
    @cached_property
137
    def stats(self):
138
        '''
139
        Get current stats object as returned by os.stat function.
140
141
        :returns: stats object
142
        :rtype: posix.stat_result or nt.stat_result
143
        '''
144
        return os.stat(self.path)
145
146
    @cached_property
147
    def pathconf(self):
148
        '''
149
        Get filesystem config for current path.
150
151
        :returns: fs config
152
        :rtype: dict
153
        '''
154
        return {key: os.pathconf(self.path, key) for key in os.pathconf_names}
155
156
    @cached_property
157
    def parent(self):
158
        '''
159
        Get parent node if available based on app config's directory_base.
160
161
        :returns: parent object if available
162
        :rtype: Node instance or None
163
        '''
164
        if check_path(self.path, self.app.config['directory_base']):
165
            return None
166
        parent = os.path.dirname(self.path) if self.path else None
167
        return self.directory_class(parent, self.app) if parent else None
168
169
    @cached_property
170
    def ancestors(self):
171
        '''
172
        Get list of ancestors until app config's directory_base is reached.
173
174
        :returns: list of ancestors starting from nearest.
175
        :rtype: list of Node objects
176
        '''
177
        ancestors = []
178
        parent = self.parent
179
        while parent:
180
            ancestors.append(parent)
181
            parent = parent.parent
182
        return ancestors
183
184
    @property
185
    def modified(self):
186
        '''
187
        Get human-readable last modification date-time.
188
189
        :returns: iso9008-like date-time string (without timezone)
190
        :rtype: str
191
        '''
192
        try:
193
            dt = datetime.datetime.fromtimestamp(self.stats.st_mtime)
194
            return dt.strftime('%Y.%m.%d %H:%M:%S')
195
        except OSError:
196
            return None
197
198
    @property
199
    def urlpath(self):
200
        '''
201
        Get the url substring corresponding to this node for those endpoints
202
        accepting a 'path' parameter, suitable for :meth:`from_urlpath`.
203
204
        :returns: relative-url-like for node's path
205
        :rtype: str
206
        '''
207
        return abspath_to_urlpath(self.path, self.app.config['directory_base'])
208
209
    @property
210
    def name(self):
211
        '''
212
        Get the basename portion of node's path.
213
214
        :returns: filename
215
        :rtype: str
216
        '''
217
        return os.path.basename(self.path)
218
219
    @property
220
    def type(self):
221
        '''
222
        Get the mime portion of node's mimetype (without the encoding part).
223
224
        :returns: mimetype
225
        :rtype: str
226
        '''
227
        return self.mimetype.split(";", 1)[0]
228
229
    @property
230
    def category(self):
231
        '''
232
        Get mimetype category (first portion of mimetype before the slash).
233
234
        :returns: mimetype category
235
        :rtype: str
236
237
        As of 2016-11-03's revision of RFC2046 it could be one of the
238
        following:
239
            * application
240
            * audio
241
            * example
242
            * image
243
            * message
244
            * model
245
            * multipart
246
            * text
247
            * video
248
        '''
249
        return self.type.split('/', 1)[0]
250
251
    def __init__(self, path=None, app=None, **defaults):
252
        '''
253
        :param path: local path
254
        :type path: str
255
        :param path: optional app instance
256
        :type path: flask.app
257
        :param **defaults: attributes will be set to object
258
        '''
259
        self.path = compat.fsdecode(path) if path else None
260
        self.app = current_app if app is None else app
261
        self.__dict__.update(defaults)  # only for attr and cached_property
262
263
    def remove(self):
264
        '''
265
        Does nothing except raising if can_remove property returns False.
266
267
        :raises: OutsideRemovableBase if :property:can_remove returns false
268
        '''
269
        if not self.can_remove:
270
            raise OutsideRemovableBase("File outside removable base")
271
272
    @classmethod
273
    def from_urlpath(cls, path, app=None):
274
        '''
275
        Alternative constructor which accepts a path as taken from URL and uses
276
        the given app or the current app config to get the real path.
277
278
        If class has attribute `generic` set to True, `directory_class` or
279
        `file_class` will be used as type.
280
281
        :param path: relative path as from URL
282
        :param app: optional, flask application
283
        :return: file object pointing to path
284
        :rtype: File
285
        '''
286
        app = app or current_app
287
        base = app.config['directory_base']
288
        path = urlpath_to_abspath(path, base)
289
        if not cls.generic:
290
            kls = cls
291
        elif os.path.isdir(path):
292
            kls = cls.directory_class
293
        else:
294
            kls = cls.file_class
295
        return kls(path=path, app=app)
296
297
    @classmethod
298
    def register_file_class(cls, kls):
299
        '''
300
        Convenience method for setting current class file_class property.
301
302
        :param kls: class to set
303
        :type kls: type
304
        :returns: given class (enabling using this as decorator)
305
        :rtype: type
306
        '''
307
        cls.file_class = kls
308
        return kls
309
310
    @classmethod
311
    def register_directory_class(cls, kls):
312
        '''
313
        Convenience method for setting current class directory_class property.
314
315
        :param kls: class to set
316
        :type kls: type
317
        :returns: given class (enabling using this as decorator)
318
        :rtype: type
319
        '''
320
        cls.directory_class = kls
321
        return kls
322
323
324
@Node.register_file_class
325
class File(Node):
326
    '''
327
    Filesystem file class.
328
329
    Some notes:
330
331
    * :attr:`can_download` is fixed to True, so Files can be downloaded
332
      inconditionaly.
333
    * :attr:`can_upload` is fixed to False, so nothing can be uploaded to
334
      file path.
335
    * :attr:`is_directory` is fixed to False, so no further checks are
336
      performed.
337
    * :attr:`generic` is set to False, so static method :meth:`from_urlpath`
338
      will always return instances of this class.
339
    '''
340
    can_download = True
341
    can_upload = False
342
    is_directory = False
343
    generic = False
344
345
    @cached_property
346
    def widgets(self):
347
        '''
348
        List widgets with filter return True for this file (or without filter).
349
350
        Entry link is prepended.
351
        Download button is prepended if :property:can_download returns true.
352
        Remove button is prepended if :property:can_remove returns true.
353
354
        :returns: list of widgets
355
        :rtype: list of namedtuple instances
356
        '''
357
        widgets = [
358
            self.plugin_manager.create_widget(
359
                'entry-link',
360
                'link',
361
                file=self,
362
                endpoint='open'
363
                )
364
            ]
365
        if self.can_download:
366
            widgets.append(
367
                self.plugin_manager.create_widget(
368
                    'entry-actions',
369
                    'button',
370
                    file=self,
371
                    css='download',
372
                    endpoint='download_file'
373
                    )
374
                )
375
        return widgets + super(File, self).widgets
376
377
    @cached_property
378
    def mimetype(self):
379
        '''
380
        Get full mimetype, with encoding if available.
381
382
        :returns: mimetype
383
        :rtype: str
384
        '''
385
        return self.plugin_manager.get_mimetype(self.path)
386
387
    @cached_property
388
    def is_file(self):
389
        '''
390
        Get if node is file.
391
392
        :returns: True if file, False otherwise
393
        :rtype: bool
394
        '''
395
        return os.path.isfile(self.path)
396
397
    @property
398
    def size(self):
399
        '''
400
        Get human-readable node size in bytes.
401
        If directory, this will corresponds with own inode size.
402
403
        :returns: fuzzy size with unit
404
        :rtype: str
405
        '''
406
        try:
407
            size, unit = fmt_size(
408
                self.stats.st_size,
409
                self.app.config['use_binary_multiples'] if self.app else False
410
                )
411
        except OSError:
412
            return None
413
        if unit == binary_units[0]:
414
            return "%d %s" % (size, unit)
415
        return "%.2f %s" % (size, unit)
416
417
    @property
418
    def encoding(self):
419
        '''
420
        Get encoding part of mimetype, or "default" if not available.
421
422
        :returns: file conding as returned by mimetype function or "default"
423
        :rtype: str
424
        '''
425
        if ";" in self.mimetype:
426
            match = self.re_charset.search(self.mimetype)
427
            gdict = match.groupdict() if match else {}
428
            return gdict.get("charset") or "default"
429
        return "default"
430
431
    def remove(self):
432
        '''
433
        Remove file.
434
        :raises OutsideRemovableBase: when not under removable base directory
435
        '''
436
        super(File, self).remove()
437
        os.unlink(self.path)
438
439
    def download(self):
440
        '''
441
        Get a Flask's send_file Response object pointing to this file.
442
443
        :returns: Response object as returned by flask's send_file
444
        :rtype: flask.Response
445
        '''
446
        directory, name = os.path.split(self.path)
447
        return send_from_directory(directory, name, as_attachment=True)
448
449
450
@Node.register_directory_class
451
class Directory(Node):
452
    '''
453
    Filesystem directory class.
454
455
    Some notes:
456
457
    * :attr:`mimetype` is fixed to 'inode/directory', so mimetype detection
458
      functions won't be called in this case.
459
    * :attr:`is_file` is fixed to False, so no further checks are needed.
460
    * :attr:`size` is fixed to 0 (zero), so stats are not required for this.
461
    * :attr:`encoding` is fixed to 'default'.
462
    * :attr:`generic` is set to False, so static method :meth:`from_urlpath`
463
      will always return instances of this class.
464
    '''
465
    _listdir_cache = None
466
    mimetype = 'inode/directory'
467
    is_file = False
468
    size = None
469
    encoding = 'default'
470
    generic = False
471
472
    @property
473
    def name(self):
474
        '''
475
        Get the basename portion of directory's path.
476
477
        :returns: filename
478
        :rtype: str
479
        '''
480
        return super(Directory, self).name or self.path
481
482
    @cached_property
483
    def widgets(self):
484
        '''
485
        List widgets with filter return True for this dir (or without filter).
486
487
        Entry link is prepended.
488
        Upload scripts and widget are added if :property:can_upload is true.
489
        Download button is prepended if :property:can_download returns true.
490
        Remove button is prepended if :property:can_remove returns true.
491
492
        :returns: list of widgets
493
        :rtype: list of namedtuple instances
494
        '''
495
        widgets = [
496
            self.plugin_manager.create_widget(
497
                'entry-link',
498
                'link',
499
                file=self,
500
                endpoint='browse'
501
                )
502
            ]
503
        if self.can_upload:
504
            widgets.extend((
505
                self.plugin_manager.create_widget(
506
                    'head',
507
                    'script',
508
                    file=self,
509
                    endpoint='static',
510
                    filename='browse.directory.head.js'
511
                ),
512
                self.plugin_manager.create_widget(
513
                    'scripts',
514
                    'script',
515
                    file=self,
516
                    endpoint='static',
517
                    filename='browse.directory.body.js'
518
                ),
519
                self.plugin_manager.create_widget(
520
                    'header',
521
                    'upload',
522
                    file=self,
523
                    text='Upload',
524
                    endpoint='upload'
525
                    )
526
                ))
527
        if self.can_download:
528
            widgets.append(
529
                self.plugin_manager.create_widget(
530
                    'entry-actions',
531
                    'button',
532
                    file=self,
533
                    css='download',
534
                    endpoint='download_directory'
535
                    )
536
                )
537
        return widgets + super(Directory, self).widgets
538
539
    @cached_property
540
    def is_directory(self):
541
        '''
542
        Get if path points to a real directory.
543
544
        :returns: True if real directory, False otherwise
545
        :rtype: bool
546
        '''
547
        return os.path.isdir(self.path)
548
549
    @cached_property
550
    def is_root(self):
551
        '''
552
        Get if directory is filesystem's root
553
554
        :returns: True if FS root, False otherwise
555
        :rtype: bool
556
        '''
557
        return check_path(os.path.dirname(self.path), self.path)
558
559
    @cached_property
560
    def can_download(self):
561
        '''
562
        Get if path is downloadable (if app's `directory_downloadable` config
563
        property is True).
564
565
        :returns: True if downloadable, False otherwise
566
        :rtype: bool
567
        '''
568
        return self.app.config['directory_downloadable']
569
570
    @cached_property
571
    def can_upload(self):
572
        '''
573
        Get if a file can be uploaded to path (if directory path is under app's
574
        `directory_upload` config property).
575
576
        :returns: True if a file can be upload to directory, False otherwise
577
        :rtype: bool
578
        '''
579
        dirbase = self.app.config["directory_upload"]
580
        return dirbase and check_base(self.path, dirbase)
581
582
    @cached_property
583
    def can_remove(self):
584
        '''
585
        Get if current node can be removed based on app config's
586
        directory_remove.
587
588
        :returns: True if current node can be removed, False otherwise.
589
        :rtype: bool
590
        '''
591
        return self.parent and super(Directory, self).can_remove
592
593
    @cached_property
594
    def is_empty(self):
595
        '''
596
        Get if directory is empty (based on :meth:`_listdir`).
597
598
        :returns: True if this directory has no entries, False otherwise.
599
        :rtype: bool
600
        '''
601
        if self._listdir_cache is not None:
602
            return bool(self._listdir_cache)
603
        for entry in self._listdir():
604
            return False
605
        return True
606
607
    def remove(self):
608
        '''
609
        Remove directory tree.
610
611
        :raises OutsideRemovableBase: when not under removable base directory
612
        '''
613
        super(Directory, self).remove()
614
        shutil.rmtree(self.path)
615
616
    def download(self):
617
        '''
618
        Get a Flask Response object streaming a tarball of this directory.
619
620
        :returns: Response object
621
        :rtype: flask.Response
622
        '''
623
        return self.app.response_class(
624
            TarFileStream(
625
                self.path,
626
                self.app.config['directory_tar_buffsize'],
627
                self.app.config['exclude_fnc'],
628
                ),
629
            mimetype="application/octet-stream"
630
            )
631
632
    def contains(self, filename):
633
        '''
634
        Check if directory contains an entry with given filename.
635
636
        :param filename: filename will be check
637
        :type filename: str
638
        :returns: True if exists, False otherwise.
639
        :rtype: bool
640
        '''
641
        return os.path.exists(os.path.join(self.path, filename))
642
643
    def choose_filename(self, filename, attempts=999):
644
        '''
645
        Get a new filename which does not colide with any entry on directory,
646
        based on given filename.
647
648
        :param filename: base filename
649
        :type filename: str
650
        :param attempts: number of attempts, defaults to 999
651
        :type attempts: int
652
        :returns: filename
653
        :rtype: str
654
655
        :raises FilenameTooLong: when filesystem filename size limit is reached
656
        :raises PathTooLong: when OS or filesystem path size limit is reached
657
        '''
658
        new_filename = filename
659
        for attempt in range(2, attempts + 1):
660
            if not self.contains(new_filename):
661
                break
662
            new_filename = alternative_filename(filename, attempt)
663
        else:
664
            while self.contains(new_filename):
665
                new_filename = alternative_filename(filename)
666
667
        pcfg = compat.pathconf(self.path)
668
669
        if pcfg.get('PC_NAME_MAX', 0) and pcfg['PC_NAME_MAX'] < len(filename):
670
            raise FilenameTooLongError(filename, pcfg['PC_NAME_MAX'])
671
672
        abspath = os.path.join(self.path, filename)
673
        if pcfg.get('PC_PATH_MAX', 0) and pcfg['PC_PATH_MAX'] < len(abspath):
674
            raise PathTooLongError(abspath, pcfg['PC_PATH_MAX'])
675
676
        return new_filename
677
678
    def _listdir(self, precomputed_stats=(os.name == 'nt')):
679
        '''
680
        Iter unsorted entries on this directory.
681
682
        :yields: Directory or File instance for each entry in directory
683
        :ytype: Node
684
        '''
685
        for entry in scandir(self.path, self.app):
686
            kwargs = {
687
                'path': entry.path,
688
                'app': self.app,
689
                'parent': self,
690
                'is_excluded': False
691
                }
692
            try:
693
                if precomputed_stats and not entry.is_symlink():
694
                    kwargs['stats'] = entry.stat()
695
                if entry.is_dir(follow_symlinks=True):
696
                    yield self.directory_class(**kwargs)
697
                else:
698
                    yield self.file_class(**kwargs)
699
            except OSError as e:
700
                logger.exception(e)
701
702
    def listdir(self, sortkey=None, reverse=False):
703
        '''
704
        Get sorted list (by given sortkey and reverse params) of File objects.
705
706
        :return: sorted list of File instances
707
        :rtype: list of File instances
708
        '''
709
        if self._listdir_cache is None:
710
            self._listdir_cache = tuple(self._listdir())
711
        if sortkey:
712
            return sorted(self._listdir_cache, key=sortkey, reverse=reverse)
713
        data = list(self._listdir_cache)
714
        if reverse:
715
            data.reverse()
716
        return data
717
718
719
def fmt_size(size, binary=True):
720
    '''
721
    Get size and unit.
722
723
    :param size: size in bytes
724
    :type size: int
725
    :param binary: whether use binary or standard units, defaults to True
726
    :type binary: bool
727
    :return: size and unit
728
    :rtype: tuple of int and unit as str
729
    '''
730
    if binary:
731
        fmt_sizes = binary_units
732
        fmt_divider = 1024.
733
    else:
734
        fmt_sizes = standard_units
735
        fmt_divider = 1000.
736
    for fmt in fmt_sizes[:-1]:
737
        if size < 1000:
738
            return (size, fmt)
739
        size /= fmt_divider
740
    return size, fmt_sizes[-1]
741
742
743
def relativize_path(path, base, os_sep=os.sep):
744
    '''
745
    Make absolute path relative to an absolute base.
746
747
    :param path: absolute path
748
    :type path: str
749
    :param base: absolute base path
750
    :type base: str
751
    :param os_sep: path component separator, defaults to current OS separator
752
    :type os_sep: str
753
    :return: relative path
754
    :rtype: str or unicode
755
    :raises OutsideDirectoryBase: if path is not below base
756
    '''
757
    if not check_base(path, base, os_sep):
758
        raise OutsideDirectoryBase("%r is not under %r" % (path, base))
759
    prefix_len = len(base)
760
    if not base.endswith(os_sep):
761
        prefix_len += len(os_sep)
762
    return path[prefix_len:]
763
764
765
def abspath_to_urlpath(path, base, os_sep=os.sep):
766
    '''
767
    Make filesystem absolute path uri relative using given absolute base path.
768
769
    :param path: absolute path
770
    :param base: absolute base path
771
    :param os_sep: path component separator, defaults to current OS separator
772
    :return: relative uri
773
    :rtype: str or unicode
774
    :raises OutsideDirectoryBase: if resulting path is not below base
775
    '''
776
    return relativize_path(path, base, os_sep).replace(os_sep, '/')
777
778
779
def urlpath_to_abspath(path, base, os_sep=os.sep):
780
    '''
781
    Make uri relative path fs absolute using a given absolute base path.
782
783
    :param path: relative path
784
    :param base: absolute base path
785
    :param os_sep: path component separator, defaults to current OS separator
786
    :return: absolute path
787
    :rtype: str or unicode
788
    :raises OutsideDirectoryBase: if resulting path is not below base
789
    '''
790
    prefix = base if base.endswith(os_sep) else base + os_sep
791
    realpath = os.path.abspath(prefix + path.replace('/', os_sep))
792
    if check_path(base, realpath) or check_under_base(realpath, base):
793
        return realpath
794
    raise OutsideDirectoryBase("%r is not under %r" % (realpath, base))
795
796
797
def generic_filename(path):
798
    '''
799
    Extract filename of given path os-indepently, taking care of known path
800
    separators.
801
802
    :param path: path
803
    :return: filename
804
    :rtype: str or unicode (depending on given path)
805
    '''
806
807
    for sep in common_path_separators:
808
        if sep in path:
809
            _, path = path.rsplit(sep, 1)
810
    return path
811
812
813
def clean_restricted_chars(path, restricted_chars=restricted_chars):
814
    '''
815
    Get path without restricted characters.
816
817
    :param path: path
818
    :return: path without restricted characters
819
    :rtype: str or unicode (depending on given path)
820
    '''
821
    for character in restricted_chars:
822
        path = path.replace(character, '_')
823
    return path
824
825
826
def check_forbidden_filename(filename,
827
                             destiny_os=os.name,
828
                             restricted_names=restricted_names):
829
    '''
830
    Get if given filename is forbidden for current OS or filesystem.
831
832
    :param filename:
833
    :param destiny_os: destination operative system
834
    :param fs_encoding: destination filesystem filename encoding
835
    :return: wether is forbidden on given OS (or filesystem) or not
836
    :rtype: bool
837
    '''
838
    return (
839
      filename in restricted_names or
840
      destiny_os == 'nt' and
841
      filename.split('.', 1)[0].upper() in nt_device_names
842
      )
843
844
845
def check_path(path, base, os_sep=os.sep):
846
    '''
847
    Check if both given paths are equal.
848
849
    :param path: absolute path
850
    :type path: str
851
    :param base: absolute base path
852
    :type base: str
853
    :param os_sep: path separator, defaults to os.sep
854
    :type base: str
855
    :return: wether two path are equal or not
856
    :rtype: bool
857
    '''
858
    base = base[:-len(os_sep)] if base.endswith(os_sep) else base
859
    return os.path.normcase(path) == os.path.normcase(base)
860
861
862
def check_base(path, base, os_sep=os.sep):
863
    '''
864
    Check if given absolute path is under or given base.
865
866
    :param path: absolute path
867
    :type path: str
868
    :param base: absolute base path
869
    :type base: str
870
    :param os_sep: path separator, defaults to os.sep
871
    :return: wether path is under given base or not
872
    :rtype: bool
873
    '''
874
    return (
875
        check_path(path, base, os_sep) or
876
        check_under_base(path, base, os_sep)
877
        )
878
879
880
def check_under_base(path, base, os_sep=os.sep):
881
    '''
882
    Check if given absolute path is under given base.
883
884
    :param path: absolute path
885
    :type path: str
886
    :param base: absolute base path
887
    :type base: str
888
    :param os_sep: path separator, defaults to os.sep
889
    :return: wether file is under given base or not
890
    :rtype: bool
891
    '''
892
    prefix = base if base.endswith(os_sep) else base + os_sep
893
    return os.path.normcase(path).startswith(os.path.normcase(prefix))
894
895
896
def secure_filename(path, destiny_os=os.name, fs_encoding=compat.FS_ENCODING):
897
    '''
898
    Get rid of parent path components and special filenames.
899
900
    If path is invalid or protected, return empty string.
901
902
    :param path: unsafe path, only basename will be used
903
    :type: str
904
    :param destiny_os: destination operative system (defaults to os.name)
905
    :type destiny_os: str
906
    :param fs_encoding: fs path encoding (defaults to detected)
907
    :type fs_encoding: str
908
    :return: filename or empty string
909
    :rtype: str
910
    '''
911
    path = generic_filename(path)
912
    path = clean_restricted_chars(
913
        path,
914
        restricted_chars=(
915
            nt_restricted_chars
916
            if destiny_os == 'nt' else
917
            restricted_chars
918
            ))
919
    path = path.strip(' .')  # required by nt, recommended for others
920
921
    if check_forbidden_filename(path, destiny_os=destiny_os):
922
        return ''
923
924
    if isinstance(path, bytes):
925
        path = path.decode('latin-1', errors=underscore_replace)
926
927
    # Decode and recover from filesystem encoding in order to strip unwanted
928
    # characters out
929
    kwargs = {
930
        'os_name': destiny_os,
931
        'fs_encoding': fs_encoding,
932
        'errors': underscore_replace,
933
        }
934
    fs_encoded_path = compat.fsencode(path, **kwargs)
935
    fs_decoded_path = compat.fsdecode(fs_encoded_path, **kwargs)
936
    return fs_decoded_path
937
938
939
def alternative_filename(filename, attempt=None):
940
    '''
941
    Generates an alternative version of given filename.
942
943
    If an number attempt parameter is given, will be used on the alternative
944
    name, a random value will be used otherwise.
945
946
    :param filename: original filename
947
    :param attempt: optional attempt number, defaults to null
948
    :return: new filename
949
    :rtype: str or unicode
950
    '''
951
    filename_parts = filename.rsplit(u'.', 2)
952
    name = filename_parts[0]
953
    ext = ''.join(u'.%s' % ext for ext in filename_parts[1:])
954
    if attempt is None:
955
        choose = random.choice
956
        extra = u' %s' % ''.join(choose(fs_safe_characters) for i in range(8))
957
    else:
958
        extra = u' (%d)' % attempt
959
    return u'%s%s%s' % (name, extra, ext)
960
961
962
def scandir(path, app=None):
963
    '''
964
    Config-aware scandir. Currently, only aware of ``exclude_fnc``.
965
966
    :param path: absolute path
967
    :type path: str
968
    :param app: flask application
969
    :type app: flask.Flask or None
970
    :returns: filtered scandir entries
971
    :rtype: iterator
972
    '''
973
    exclude = app and app.config.get('exclude_fnc')
974
    if exclude:
975
        return (
976
            item
977
            for item in compat.scandir(path)
978
            if not exclude(item.path)
979
            )
980
    return compat.scandir(path)
981