Completed
Pull Request — master (#30)
by
unknown
38s
created

Directory.mkdir()   A

Complexity

Conditions 1

Size

Total Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

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