Completed
Push — 0.5.3 ( ed73f0...36ca0e )
by Felipe A.
01:01
created

create_exclude()   A

Complexity

Conditions 2

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Importance

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