Completed
Push — 0.5.4 ( 47417a )
by Felipe A.
01:23
created

Node.pathconf()   A

Complexity

Conditions 2

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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