Completed
Push — 0.5.3 ( 279fbf...b6802f )
by Felipe A.
01:21
created

Node.drive()   A

Complexity

Conditions 1

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