Document FTP: fix walking utf8 paths.
[odoo/odoo.git] / addons / document_ftp / ftpserver / abstracted_fs.py
1 # -*- encoding: utf-8 -*-
2 import os
3 import time
4 from tarfile import filemode
5 import StringIO
6 import base64
7 import logging
8 import errno
9
10 import glob
11 import fnmatch
12
13 import pooler
14 import netsvc
15 import os
16 from service import security
17 from osv import osv
18 #from document.nodes import node_res_dir, node_res_obj
19 from document.nodes import get_node_context
20 import stat
21
22 def _get_month_name(month):
23     month=int(month)
24     if month==1:return 'Jan'
25     elif month==2:return 'Feb'
26     elif month==3:return 'Mar'
27     elif month==4:return 'Apr'
28     elif month==5:return 'May'
29     elif month==6:return 'Jun'
30     elif month==7:return 'Jul'
31     elif month==8:return 'Aug'
32     elif month==9:return 'Sep'
33     elif month==10:return 'Oct'
34     elif month==11:return 'Nov'
35     elif month==12:return 'Dec'
36
37 def _to_unicode(s):
38     try:
39         return s.decode('utf-8')
40     except UnicodeError:
41         try:
42             return s.decode('latin')
43         except UnicodeError:
44             try:
45                 return s.encode('ascii')
46             except UnicodeError:
47                 return s
48
49 def _to_decode(s):
50     try:
51         return s.encode('utf-8')
52     except UnicodeError:
53         try:
54             return s.encode('latin')
55         except UnicodeError:
56             try:
57                 return s.decode('ascii')
58             except UnicodeError:
59                 return s  
60
61 class abstracted_fs(object):
62     """A class used to interact with the file system, providing a high
63     level, cross-platform interface compatible with both Windows and
64     UNIX style filesystems.
65
66     It provides some utility methods and some wraps around operations
67     involved in file creation and file system operations like moving
68     files or removing directories.
69
70     Instance attributes:
71      - (str) root: the user home directory.
72      - (str) cwd: the current working directory.
73      - (str) rnfr: source file to be renamed.
74
75     """
76
77     def __init__(self):
78         self.root = None
79         self.cwd = '/'
80         self.cwd_node = None
81         self.rnfr = None
82         self._log = logging.getLogger('FTP.fs')
83
84     # Ok
85     def db_list(self):
86         """Get the list of available databases, with FTPd support
87         """
88         s = netsvc.ExportService.getService('db')
89         result = s.exp_list(document=True)
90         self.db_name_list = []
91         for db_name in result:
92             db, cr = None, None
93             try:
94                 try:
95                     db = pooler.get_db_only(db_name)
96                     cr = db.cursor()
97                     cr.execute("SELECT 1 FROM pg_class WHERE relkind = 'r' AND relname = 'ir_module_module'")
98                     if not cr.fetchone():
99                         continue
100     
101                     cr.execute("SELECT id FROM ir_module_module WHERE name = 'document_ftp' AND state IN ('installed', 'to upgrade') ")
102                     res = cr.fetchone()
103                     if res and len(res):
104                         self.db_name_list.append(db_name)
105                     cr.commit()
106                 except Exception:
107                     self._log.warning('Cannot use db "%s"', db_name)
108             finally:
109                 if cr is not None:
110                     cr.close()
111                 #if db is not None:
112                 #    pooler.close_db(db_name)        
113         return self.db_name_list
114
115     def ftpnorm(self, ftppath):
116         """Normalize a "virtual" ftp pathname (tipically the raw string
117         coming from client).
118
119         Pathname returned is relative!.
120         """
121         p = os.path.normpath(ftppath)
122         # normalize string in a standard web-path notation having '/'
123         # as separator. xrg: is that really in the spec?
124         p = p.replace("\\", "/")
125         # os.path.normpath supports UNC paths (e.g. "//a/b/c") but we
126         # don't need them.  In case we get an UNC path we collapse
127         # redundant separators appearing at the beginning of the string
128         while p[:2] == '//':
129             p = p[1:]
130         if p == '.':
131             return ''
132         return p
133
134     def get_cwd(self):
135         """ return the cwd, decoded in utf"""
136         return _to_decode(self.cwd)
137
138     def ftp2fs(self, path_orig, data):
139         raise DeprecationWarning()
140
141     def fs2ftp(self, node):        
142         """ Return the string path of a node, in ftp form
143         """
144         res='/'
145         if node:
146             paths = node.full_path()
147             res = '/' + node.context.dbname + '/' +  \
148                 _to_decode(os.path.join(*paths))
149             
150         return res
151
152     def validpath(self, path):
153         """Check whether the path belongs to user's home directory.
154         Expected argument is a datacr tuple
155         """
156         # TODO: are we called for "/" ?
157         return isinstance(path, tuple) and path[1] and True or False
158
159     # --- Wrapper methods around open() and tempfile.mkstemp
160
161     def create(self, datacr, objname, mode):
162         """ Create a children file-node under node, open it
163             @return open node_descriptor of the created node
164         """
165         objname = _to_unicode(objname) 
166         cr , node, rem = datacr
167         try:
168             child = node.child(cr, objname)
169             if child:
170                 if child.type not in ('file','content'):
171                     raise OSError(1, 'Operation not permited.')
172
173                 ret = child.open_data(cr, mode)
174                 return ret
175         except EnvironmentError:
176             raise
177         except Exception,e:
178             self._log.exception('Cannot locate item %s at node %s', objname, repr(node))
179             pass
180
181         try:
182             child = node.create_child(cr, objname, data=None)
183             return child.open_data(cr, mode)
184         except Exception,e:
185             self._log.exception('Cannot create item %s at node %s', objname, repr(node))
186             raise OSError(1, 'Operation not permited.')
187
188     def open(self, datacr, mode):
189         if not (datacr and datacr[1]):
190             raise OSError(1, 'Operation not permited.')
191         # Reading operation
192         cr, node, rem = datacr
193         res = node.open_data(cr, mode)
194         return res
195
196     # ok, but need test more
197
198     def mkstemp(self, suffix='', prefix='', dir=None, mode='wb'):
199         """A wrap around tempfile.mkstemp creating a file with a unique
200         name.  Unlike mkstemp it returns an object with a file-like
201         interface.
202         """
203         raise NotImplementedError
204
205         text = not 'b' in mode
206         # for unique file , maintain version if duplicate file
207         if dir:
208             # TODO
209             cr = dir.cr
210             uid = dir.uid
211             pool = pooler.get_pool(node.context.dbname)
212             object=dir and dir.object or False
213             object2=dir and dir.object2 or False
214             res=pool.get('ir.attachment').search(cr,uid,[('name','like',prefix),('parent_id','=',object and object.type in ('directory','ressource') and object.id or False),('res_id','=',object2 and object2.id or False),('res_model','=',object2 and object2._name or False)])
215             if len(res):
216                 pre = prefix.split('.')
217                 prefix=pre[0] + '.v'+str(len(res))+'.'+pre[1]
218             #prefix = prefix + '.'
219         return self.create(dir,suffix+prefix,text)
220
221
222
223     # Ok
224     def chdir(self, datacr):
225         if (not datacr) or datacr == (None, None, None):
226             self.cwd = '/'
227             self.cwd_node = None
228             return None        
229         if not datacr[1]:
230             raise OSError(1, 'Operation not permitted')
231         if datacr[1].type not in  ('collection','database'):
232             raise OSError(2, 'Path is not a directory')
233         self.cwd = '/'+datacr[1].context.dbname + '/'
234         self.cwd += '/'.join(datacr[1].full_path())
235         self.cwd_node = datacr[1]
236
237     # Ok
238     def mkdir(self, datacr, basename):
239         """Create the specified directory."""
240         cr, node, rem = datacr or (None, None, None)
241         if not node:
242             raise OSError(1, 'Operation not permited.')
243         
244         try:
245             basename =_to_unicode(basename)
246             cdir = node.create_child_collection(cr, basename)
247             self._log.debug("Created child dir: %r", cdir)
248             cr.commit()
249         except Exception,e:
250             self._log.exception('Cannot create dir "%s" at node %s', basename, repr(node))
251             raise OSError(1, 'Operation not permited.')
252
253     def close_cr(self, data):
254         if data and data[0]:
255             data[0].close()
256         return True
257
258     def get_cr(self, pathname):
259         raise DeprecationWarning()
260     
261     def get_crdata(self, line, mode='file'):
262         """ Get database cursor, node and remainder data, for commands
263         
264         This is the helper function that will prepare the arguments for
265         any of the subsequent commands.
266         It returns a tuple in the form of:
267         @code        ( cr, node, rem_path=None )
268         
269         @param line An absolute or relative ftp path, as passed to the cmd.
270         @param mode A word describing the mode of operation, so that this
271                     function behaves properly in the different commands.
272         """
273         path = self.ftpnorm(line)
274         if self.cwd_node is None:
275             if not os.path.isabs(path):
276                 path = os.path.join(self.root, path)
277
278         if path == '/' and mode in ('list', 'cwd'):
279             return (None, None, None )
280
281         path = _to_unicode(os.path.normpath(path)) # again, for '/db/../ss'
282         if path == '.': path = ''
283
284         if os.path.isabs(path) and self.cwd_node is not None \
285                 and path.startswith(self.cwd):
286             # make relative, so that cwd_node is used again
287             path = path[len(self.cwd):]
288             if path.startswith('/'):
289                 path = path[1:]
290
291         p_parts = path.split('/') # hard-code the unix sep here, by spec.
292
293         assert '..' not in p_parts
294
295         rem_path = None
296         if mode in ('create',):
297             rem_path = p_parts[-1]
298             p_parts = p_parts[:-1]
299
300         if os.path.isabs(path):
301             # we have to start from root, again
302             while p_parts[0] == '':
303                 p_parts = p_parts[1:]
304             # self._log.debug("Path parts: %r ", p_parts)
305             if not p_parts:
306                 raise IOError(errno.EPERM, 'Cannot perform operation at root dir')
307             dbname = p_parts[0]
308             if dbname not in self.db_list():
309                 raise IOError(errno.ENOENT,'Invalid database path')
310             try:
311                 db = pooler.get_db(dbname)
312             except Exception:
313                 raise OSError(1, 'Database cannot be used.')
314             cr = db.cursor()
315             try:
316                 uid = security.login(dbname, self.username, self.password)
317             except Exception:
318                 cr.close()
319                 raise
320             if not uid:
321                 cr.close()
322                 raise OSError(2, 'Authentification Required.')
323             n = get_node_context(cr, uid, {})
324             node = n.get_uri(cr, p_parts[1:])
325             # self._log.debug("get_crdata(abs): %r" % ( (cr, node, rem_path),))
326             return (cr, node, rem_path)
327         else:
328             # we never reach here if cwd_node is not set
329             if p_parts and p_parts[-1] == '':
330                 p_parts = p_parts[:-1]
331             cr, uid = self.get_node_cr_uid(self.cwd_node)
332             if p_parts:
333                 node = self.cwd_node.get_uri(cr, p_parts)
334             else:
335                 node = self.cwd_node
336             if node is False and mode not in ('???'):
337                 cr.close()
338                 raise IOError(errno.ENOENT, 'Path does not exist')
339             # self._log.debug("get_crdata(rel): %r" % ( (cr, node, rem_path),))
340             return (cr, node, rem_path)
341
342     def get_node_cr_uid(self, node):
343         """ Get cr, uid, pool from a node
344         """
345         assert node
346         db = pooler.get_db(node.context.dbname)
347         return db.cursor(), node.context.uid
348         
349     def get_node_cr(self, node):
350         """ Get the cursor for the database of a node
351         
352         The cursor is the only thing that a node will not store 
353         persistenly, so we have to obtain a new one for each call.
354         """
355         return self.get_node_cr_uid(node)[0]
356         
357     def listdir(self, datacr):
358         """List the content of a directory."""
359         class false_node(object):
360             write_date = 0.0
361             create_date = 0.0
362             unixperms = 040550
363             content_length = 0L
364             uuser = 'root'
365             ugroup = 'root'
366             type = 'database'
367             
368             def __init__(self, db):
369                 self.path = db
370
371         if datacr[1] is None:
372             result = []
373             for db in self.db_list():
374                 try:
375                     result.append(false_node(db))
376                 except osv.except_osv:
377                     pass
378             return result
379         cr, node, rem = datacr
380         res = node.children(cr)
381         return res
382
383     def rmdir(self, datacr):
384         """Remove the specified directory."""
385         cr, node, rem = datacr
386         assert node
387         cr = self.get_node_cr(node)
388         node.rmcol(cr)
389         cr.commit()
390
391     def remove(self, datacr):
392         assert datacr[1]
393         if datacr[1].type == 'collection':
394             return self.rmdir(datacr)
395         elif datacr[1].type == 'file':
396             return self.rmfile(datacr)
397         raise OSError(1, 'Operation not permited.')
398
399     def rmfile(self, datacr):
400         """Remove the specified file."""
401         assert datacr[1]
402         cr = datacr[0]
403         datacr[1].rm(cr)
404         cr.commit()
405
406     def rename(self, src, datacr):
407         """ Renaming operation, the effect depends on the src:
408             * A file: read, create and remove
409             * A directory: change the parent and reassign childs to ressource
410         """
411         cr = datacr[0]
412         try:
413             nname = _to_unicode(datacr[2])
414             ret = src.move_to(cr, datacr[1], new_name=nname)
415             # API shouldn't wait for us to write the object
416             assert (ret is True) or (ret is False)
417             cr.commit()
418         except Exception,err:
419             self._log.exception('Cannot rename "%s" to "%s" at "%s"', src, dst_basename, dst_basedir)
420             raise OSError(1,'Operation not permited.')
421
422     def stat(self, node):
423         raise NotImplementedError()
424
425     # --- Wrapper methods around os.path.*
426
427     # Ok
428     def isfile(self, node):
429         if node and (node.type in ('file','content')):
430             return True
431         return False
432
433     # Ok
434     def islink(self, path):
435         """Return True if path is a symbolic link."""
436         return False
437
438     def isdir(self, node):
439         """Return True if path is a directory."""
440         if node is None:
441             return True
442         if node and (node.type in ('collection','database')):
443             return True
444         return False
445
446     def getsize(self, datacr):
447         """Return the size of the specified file in bytes."""
448         if not (datacr and datacr[1]):
449             return 0L
450         if datacr[1].type in ('file', 'content'):
451             return datacr[1].content_length or 0L
452         return 0L
453
454     # Ok
455     def getmtime(self, datacr):
456         """Return the last modified time as a number of seconds since
457         the epoch."""
458         
459         node = datacr[1]
460         if node.write_date or node.create_date:
461             dt = (node.write_date or node.create_date)[:19]
462             result = time.mktime(time.strptime(dt, '%Y-%m-%d %H:%M:%S'))
463         else:
464             result = time.mktime(time.localtime())
465         return result
466
467     # Ok
468     def realpath(self, path):
469         """Return the canonical version of path eliminating any
470         symbolic links encountered in the path (if they are
471         supported by the operating system).
472         """
473         return path
474
475     # Ok
476     def lexists(self, path):
477         """Return True if path refers to an existing path, including
478         a broken or circular symbolic link.
479         """
480         raise DeprecationWarning()
481         return path and True or False
482
483     exists = lexists
484
485     # Ok, can be improved
486     def glob1(self, dirname, pattern):
487         """Return a list of files matching a dirname pattern
488         non-recursively.
489
490         Unlike glob.glob1 raises exception if os.listdir() fails.
491         """
492         names = self.listdir(dirname)
493         if pattern[0] != '.':
494             names = filter(lambda x: x.path[0] != '.', names)
495         return fnmatch.filter(names, pattern)
496
497     # --- Listing utilities
498
499     # note: the following operations are no more blocking
500
501     def get_list_dir(self, datacr):
502         """"Return an iterator object that yields a directory listing
503         in a form suitable for LIST command.
504         """        
505         if not datacr:
506             return None
507         elif self.isdir(datacr[1]):
508             listing = self.listdir(datacr)
509             #listing.sort()
510             return self.format_list(datacr[0], datacr[1], listing)
511         # if path is a file or a symlink we return information about it
512         elif self.isfile(datacr[1]):
513             par = datacr[1].parent
514             return self.format_list(datacr[0], par, [datacr[1]])
515
516     def get_stat_dir(self, rawline, datacr):
517         """Return an iterator object that yields a list of files
518         matching a dirname pattern non-recursively in a form
519         suitable for STAT command.
520
521          - (str) rawline: the raw string passed by client as command
522          argument.
523         """
524         ftppath = self.ftpnorm(rawline)
525         if not glob.has_magic(ftppath):
526             return self.get_list_dir(self.ftp2fs(rawline, datacr))
527         else:
528             basedir, basename = os.path.split(ftppath)
529             if glob.has_magic(basedir):
530                 return iter(['Directory recursion not supported.\r\n'])
531             else:
532                 basedir = self.ftp2fs(basedir, datacr)
533                 listing = self.glob1(basedir, basename)
534                 if listing:
535                     listing.sort()
536                 return self.format_list(basedir, listing)
537
538     def format_list(self, cr, parent_node, listing, ignore_err=True):
539         """Return an iterator object that yields the entries of given
540         directory emulating the "/bin/ls -lA" UNIX command output.
541
542          - (str) basedir: the parent directory node. Can be None
543          - (list) listing: a list of nodes
544          - (bool) ignore_err: when False raise exception if os.lstat()
545          call fails.
546
547         On platforms which do not support the pwd and grp modules (such
548         as Windows), ownership is printed as "owner" and "group" as a
549         default, and number of hard links is always "1". On UNIX
550         systems, the actual owner, group, and number of links are
551         printed.
552
553         This is how output appears to client:
554
555         -rw-rw-rw-   1 owner   group    7045120 Sep 02  3:47 music.mp3
556         drwxrwxrwx   1 owner   group          0 Aug 31 18:50 e-books
557         -rw-rw-rw-   1 owner   group        380 Sep 02  3:40 module.py
558         """
559         for node in listing:
560             perms = filemode(node.unixperms)  # permissions
561             nlinks = 1
562             size = node.content_length or 0L
563             uname = node.uuser
564             gname = node.ugroup
565             # stat.st_mtime could fail (-1) if last mtime is too old
566             # in which case we return the local time as last mtime
567             try:
568                 st_mtime = node.write_date or 0.0
569                 if isinstance(st_mtime, basestring):
570                     st_mtime = time.strptime(st_mtime, '%Y-%m-%d %H:%M:%S')
571                 elif isinstance(st_mtime, float):
572                     st_mtime = time.localtime(st_mtime)
573                 mname=_get_month_name(time.strftime("%m", st_mtime ))
574                 mtime = mname+' '+time.strftime("%d %H:%M", st_mtime)
575             except ValueError:
576                 mname=_get_month_name(time.strftime("%m"))
577                 mtime = mname+' '+time.strftime("%d %H:%M")            
578             fpath = node.path
579             if isinstance(fpath, (list, tuple)):
580                 fpath = fpath[-1]
581             # formatting is matched with proftpd ls output            
582             path=_to_decode(fpath)
583             yield "%s %3s %-8s %-8s %8s %s %s\r\n" %(perms, nlinks, uname, gname,
584                                                      size, mtime, path)
585
586     # Ok
587     def format_mlsx(self, cr, basedir, listing, perms, facts, ignore_err=True):
588         """Return an iterator object that yields the entries of a given
589         directory or of a single file in a form suitable with MLSD and
590         MLST commands.
591
592         Every entry includes a list of "facts" referring the listed
593         element.  See RFC-3659, chapter 7, to see what every single
594         fact stands for.
595
596          - (str) basedir: the absolute dirname.
597          - (list) listing: the names of the entries in basedir
598          - (str) perms: the string referencing the user permissions.
599          - (str) facts: the list of "facts" to be returned.
600          - (bool) ignore_err: when False raise exception if os.stat()
601          call fails.
602
603         Note that "facts" returned may change depending on the platform
604         and on what user specified by using the OPTS command.
605
606         This is how output could appear to the client issuing
607         a MLSD request:
608
609         type=file;size=156;perm=r;modify=20071029155301;unique=801cd2; music.mp3
610         type=dir;size=0;perm=el;modify=20071127230206;unique=801e33; ebooks
611         type=file;size=211;perm=r;modify=20071103093626;unique=801e32; module.py
612         """
613         permdir = ''.join([x for x in perms if x not in 'arw'])
614         permfile = ''.join([x for x in perms if x not in 'celmp'])
615         if ('w' in perms) or ('a' in perms) or ('f' in perms):
616             permdir += 'c'
617         if 'd' in perms:
618             permdir += 'p'
619         type = size = perm = modify = create = unique = mode = uid = gid = ""
620         for node in listing:
621             # type + perm
622             if self.isdir(node):
623                 if 'type' in facts:
624                     type = 'type=dir;'                    
625                 if 'perm' in facts:
626                     perm = 'perm=%s;' %permdir
627             else:
628                 if 'type' in facts:
629                     type = 'type=file;'
630                 if 'perm' in facts:
631                     perm = 'perm=%s;' %permfile
632             if 'size' in facts:
633                 size = 'size=%s;' % (node.content_length or 0L)
634             # last modification time
635             if 'modify' in facts:
636                 try:
637                     st_mtime = node.write_date or 0.0
638                     if isinstance(st_mtime, basestring):
639                         st_mtime = time.strptime(st_mtime, '%Y-%m-%d %H:%M:%S')
640                     elif isinstance(st_mtime, float):
641                         st_mtime = time.localtime(st_mtime)
642                     modify = 'modify=%s;' %time.strftime("%Y%m%d%H%M%S", st_mtime)
643                 except ValueError:
644                     # stat.st_mtime could fail (-1) if last mtime is too old
645                     modify = ""
646             if 'create' in facts:
647                 # on Windows we can provide also the creation time
648                 try:
649                     st_ctime = node.create_date or 0.0
650                     if isinstance(st_ctime, basestring):
651                         st_ctime = time.strptime(st_ctime, '%Y-%m-%d %H:%M:%S')
652                     elif isinstance(st_mtime, float):
653                         st_ctime = time.localtime(st_ctime)
654                     create = 'create=%s;' %time.strftime("%Y%m%d%H%M%S",st_ctime)
655                 except ValueError:
656                     create = ""
657             # UNIX only
658             if 'unix.mode' in facts:
659                 mode = 'unix.mode=%s;' %oct(node.unixperms & 0777)
660             if 'unix.uid' in facts:
661                 uid = 'unix.uid=%s;' % node.uuser
662             if 'unix.gid' in facts:
663                 gid = 'unix.gid=%s;' % node.ugroup
664             # We provide unique fact (see RFC-3659, chapter 7.5.2) on
665             # posix platforms only; we get it by mixing st_dev and
666             # st_ino values which should be enough for granting an
667             # uniqueness for the file listed.
668             # The same approach is used by pure-ftpd.
669             # Implementors who want to provide unique fact on other
670             # platforms should use some platform-specific method (e.g.
671             # on Windows NTFS filesystems MTF records could be used).
672             # if 'unique' in facts: todo
673             #    unique = "unique=%x%x;" %(st.st_dev, st.st_ino)
674             path = node.path
675             if isinstance (path, (list, tuple)):
676                 path = path[-1]
677             path=_to_decode(path)
678             yield "%s%s%s%s%s%s%s%s%s %s\r\n" %(type, size, perm, modify, create,
679                                                 mode, uid, gid, unique, path)
680
681 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
682