Document ftp: better handling of wrong 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='installed' ")
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 = 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             p_parts = p_parts[1:]
303             if not p_parts:
304                 raise IOError(errno.EPERM, 'Cannot perform operation at root dir')
305             dbname = p_parts[0]
306             if dbname not in self.db_list():
307                 return IOError(errno.ENOENT,'Invalid database path')
308             try:
309                 db = pooler.get_db(dbname)
310             except Exception:
311                 raise OSError(1, 'Database cannot be used.')
312             cr = db.cursor()
313             try:
314                 uid = security.login(dbname, self.username, self.password)
315             except Exception:
316                 cr.close()
317                 raise
318             if not uid:
319                 cr.close()
320                 raise OSError(2, 'Authentification Required.')
321             n = get_node_context(cr, uid, {})
322             node = n.get_uri(cr, p_parts[1:])
323             return (cr, node, rem_path)
324         else:
325             # we never reach here if cwd_node is not set
326             if p_parts and p_parts[-1] == '':
327                 p_parts = p_parts[:-1]
328             cr, uid = self.get_node_cr_uid(self.cwd_node)
329             if p_parts:
330                 node = self.cwd_node.get_uri(cr, p_parts)
331             else:
332                 node = self.cwd_node
333             if node is False and mode not in ('???'):
334                 cr.close()
335                 raise IOError(errno.ENOENT, 'Path does not exist')
336             return (cr, node, rem_path)
337
338     def get_node_cr_uid(self, node):
339         """ Get cr, uid, pool from a node
340         """
341         assert node
342         db = pooler.get_db(node.context.dbname)
343         return db.cursor(), node.context.uid
344         
345     def get_node_cr(self, node):
346         """ Get the cursor for the database of a node
347         
348         The cursor is the only thing that a node will not store 
349         persistenly, so we have to obtain a new one for each call.
350         """
351         return self.get_node_cr_uid(node)[0]
352         
353     def listdir(self, datacr):
354         """List the content of a directory."""
355         class false_node(object):
356             write_date = 0.0
357             create_date = 0.0
358             unixperms = 040550
359             content_length = 0L
360             uuser = 'root'
361             ugroup = 'root'
362             type = 'database'
363             
364             def __init__(self, db):
365                 self.path = db
366
367         if datacr[1] is None:
368             result = []
369             for db in self.db_list():
370                 try:
371                     result.append(false_node(db))
372                 except osv.except_osv:
373                     pass
374             return result
375         cr, node, rem = datacr
376         res = node.children(cr)
377         return res
378
379     def rmdir(self, datacr):
380         """Remove the specified directory."""
381         cr, node, rem = datacr
382         assert node
383         cr = self.get_node_cr(node)
384         node.rmcol(cr)
385         cr.commit()
386
387     def remove(self, datacr):
388         assert datacr[1]
389         if datacr[1].type == 'collection':
390             return self.rmdir(datacr)
391         elif datacr[1].type == 'file':
392             return self.rmfile(datacr)
393         raise OSError(1, 'Operation not permited.')
394
395     def rmfile(self, datacr):
396         """Remove the specified file."""
397         assert datacr[1]
398         cr = datacr[0]
399         datacr[1].rm(cr)
400         cr.commit()
401
402     def rename(self, src, datacr):
403         """ Renaming operation, the effect depends on the src:
404             * A file: read, create and remove
405             * A directory: change the parent and reassign childs to ressource
406         """
407         cr = datacr[0]
408         try:
409             nname = _to_unicode(datacr[2])
410             ret = src.move_to(cr, datacr[1], new_name=nname)
411             # API shouldn't wait for us to write the object
412             assert (ret is True) or (ret is False)
413             cr.commit()
414         except Exception,err:
415             self._log.exception('Cannot rename "%s" to "%s" at "%s"', src, dst_basename, dst_basedir)
416             raise OSError(1,'Operation not permited.')
417
418     def stat(self, node):
419         raise NotImplementedError()
420
421     # --- Wrapper methods around os.path.*
422
423     # Ok
424     def isfile(self, node):
425         if node and (node.type in ('file','content')):
426             return True
427         return False
428
429     # Ok
430     def islink(self, path):
431         """Return True if path is a symbolic link."""
432         return False
433
434     def isdir(self, node):
435         """Return True if path is a directory."""
436         if node is None:
437             return True
438         if node and (node.type in ('collection','database')):
439             return True
440         return False
441
442     def getsize(self, datacr):
443         """Return the size of the specified file in bytes."""
444         if not (datacr and datacr[1]):
445             return 0L
446         if datacr[1].type in ('file', 'content'):
447             return datacr[1].content_length or 0L
448         return 0L
449
450     # Ok
451     def getmtime(self, datacr):
452         """Return the last modified time as a number of seconds since
453         the epoch."""
454         
455         node = datacr[1]
456         if node.write_date or node.create_date:
457             dt = (node.write_date or node.create_date)[:19]
458             result = time.mktime(time.strptime(dt, '%Y-%m-%d %H:%M:%S'))
459         else:
460             result = time.mktime(time.localtime())
461         return result
462
463     # Ok
464     def realpath(self, path):
465         """Return the canonical version of path eliminating any
466         symbolic links encountered in the path (if they are
467         supported by the operating system).
468         """
469         return path
470
471     # Ok
472     def lexists(self, path):
473         """Return True if path refers to an existing path, including
474         a broken or circular symbolic link.
475         """
476         raise DeprecationWarning()
477         return path and True or False
478
479     exists = lexists
480
481     # Ok, can be improved
482     def glob1(self, dirname, pattern):
483         """Return a list of files matching a dirname pattern
484         non-recursively.
485
486         Unlike glob.glob1 raises exception if os.listdir() fails.
487         """
488         names = self.listdir(dirname)
489         if pattern[0] != '.':
490             names = filter(lambda x: x.path[0] != '.', names)
491         return fnmatch.filter(names, pattern)
492
493     # --- Listing utilities
494
495     # note: the following operations are no more blocking
496
497     def get_list_dir(self, datacr):
498         """"Return an iterator object that yields a directory listing
499         in a form suitable for LIST command.
500         """        
501         if not datacr:
502             return None
503         elif self.isdir(datacr[1]):
504             listing = self.listdir(datacr)
505             #listing.sort()
506             return self.format_list(datacr[0], datacr[1], listing)
507         # if path is a file or a symlink we return information about it
508         elif self.isfile(datacr[1]):
509             par = datacr[1].parent
510             return self.format_list(datacr[0], par, [datacr[1]])
511
512     def get_stat_dir(self, rawline, datacr):
513         """Return an iterator object that yields a list of files
514         matching a dirname pattern non-recursively in a form
515         suitable for STAT command.
516
517          - (str) rawline: the raw string passed by client as command
518          argument.
519         """
520         ftppath = self.ftpnorm(rawline)
521         if not glob.has_magic(ftppath):
522             return self.get_list_dir(self.ftp2fs(rawline, datacr))
523         else:
524             basedir, basename = os.path.split(ftppath)
525             if glob.has_magic(basedir):
526                 return iter(['Directory recursion not supported.\r\n'])
527             else:
528                 basedir = self.ftp2fs(basedir, datacr)
529                 listing = self.glob1(basedir, basename)
530                 if listing:
531                     listing.sort()
532                 return self.format_list(basedir, listing)
533
534     def format_list(self, cr, parent_node, listing, ignore_err=True):
535         """Return an iterator object that yields the entries of given
536         directory emulating the "/bin/ls -lA" UNIX command output.
537
538          - (str) basedir: the parent directory node. Can be None
539          - (list) listing: a list of nodes
540          - (bool) ignore_err: when False raise exception if os.lstat()
541          call fails.
542
543         On platforms which do not support the pwd and grp modules (such
544         as Windows), ownership is printed as "owner" and "group" as a
545         default, and number of hard links is always "1". On UNIX
546         systems, the actual owner, group, and number of links are
547         printed.
548
549         This is how output appears to client:
550
551         -rw-rw-rw-   1 owner   group    7045120 Sep 02  3:47 music.mp3
552         drwxrwxrwx   1 owner   group          0 Aug 31 18:50 e-books
553         -rw-rw-rw-   1 owner   group        380 Sep 02  3:40 module.py
554         """
555         for node in listing:
556             perms = filemode(node.unixperms)  # permissions
557             nlinks = 1
558             size = node.content_length or 0L
559             uname = node.uuser
560             gname = node.ugroup
561             # stat.st_mtime could fail (-1) if last mtime is too old
562             # in which case we return the local time as last mtime
563             try:
564                 st_mtime = node.write_date or 0.0
565                 if isinstance(st_mtime, basestring):
566                     st_mtime = time.strptime(st_mtime, '%Y-%m-%d %H:%M:%S')
567                 elif isinstance(st_mtime, float):
568                     st_mtime = time.localtime(st_mtime)
569                 mname=_get_month_name(time.strftime("%m", st_mtime ))
570                 mtime = mname+' '+time.strftime("%d %H:%M", st_mtime)
571             except ValueError:
572                 mname=_get_month_name(time.strftime("%m"))
573                 mtime = mname+' '+time.strftime("%d %H:%M")            
574             fpath = node.path
575             if isinstance(fpath, (list, tuple)):
576                 fpath = fpath[-1]
577             # formatting is matched with proftpd ls output            
578             path=_to_decode(fpath)
579             yield "%s %3s %-8s %-8s %8s %s %s\r\n" %(perms, nlinks, uname, gname,
580                                                      size, mtime, path)
581
582     # Ok
583     def format_mlsx(self, cr, basedir, listing, perms, facts, ignore_err=True):
584         """Return an iterator object that yields the entries of a given
585         directory or of a single file in a form suitable with MLSD and
586         MLST commands.
587
588         Every entry includes a list of "facts" referring the listed
589         element.  See RFC-3659, chapter 7, to see what every single
590         fact stands for.
591
592          - (str) basedir: the absolute dirname.
593          - (list) listing: the names of the entries in basedir
594          - (str) perms: the string referencing the user permissions.
595          - (str) facts: the list of "facts" to be returned.
596          - (bool) ignore_err: when False raise exception if os.stat()
597          call fails.
598
599         Note that "facts" returned may change depending on the platform
600         and on what user specified by using the OPTS command.
601
602         This is how output could appear to the client issuing
603         a MLSD request:
604
605         type=file;size=156;perm=r;modify=20071029155301;unique=801cd2; music.mp3
606         type=dir;size=0;perm=el;modify=20071127230206;unique=801e33; ebooks
607         type=file;size=211;perm=r;modify=20071103093626;unique=801e32; module.py
608         """
609         permdir = ''.join([x for x in perms if x not in 'arw'])
610         permfile = ''.join([x for x in perms if x not in 'celmp'])
611         if ('w' in perms) or ('a' in perms) or ('f' in perms):
612             permdir += 'c'
613         if 'd' in perms:
614             permdir += 'p'
615         type = size = perm = modify = create = unique = mode = uid = gid = ""
616         for node in listing:
617             # type + perm
618             if self.isdir(node):
619                 if 'type' in facts:
620                     type = 'type=dir;'                    
621                 if 'perm' in facts:
622                     perm = 'perm=%s;' %permdir
623             else:
624                 if 'type' in facts:
625                     type = 'type=file;'
626                 if 'perm' in facts:
627                     perm = 'perm=%s;' %permfile
628             if 'size' in facts:
629                 size = 'size=%s;' % (node.content_length or 0L)
630             # last modification time
631             if 'modify' in facts:
632                 try:
633                     st_mtime = node.write_date or 0.0
634                     if isinstance(st_mtime, basestring):
635                         st_mtime = time.strptime(st_mtime, '%Y-%m-%d %H:%M:%S')
636                     elif isinstance(st_mtime, float):
637                         st_mtime = time.localtime(st_mtime)
638                     modify = 'modify=%s;' %time.strftime("%Y%m%d%H%M%S", st_mtime)
639                 except ValueError:
640                     # stat.st_mtime could fail (-1) if last mtime is too old
641                     modify = ""
642             if 'create' in facts:
643                 # on Windows we can provide also the creation time
644                 try:
645                     st_ctime = node.create_date or 0.0
646                     if isinstance(st_ctime, basestring):
647                         st_ctime = time.strptime(st_ctime, '%Y-%m-%d %H:%M:%S')
648                     elif isinstance(st_mtime, float):
649                         st_ctime = time.localtime(st_ctime)
650                     create = 'create=%s;' %time.strftime("%Y%m%d%H%M%S",st_ctime)
651                 except ValueError:
652                     create = ""
653             # UNIX only
654             if 'unix.mode' in facts:
655                 mode = 'unix.mode=%s;' %oct(node.unixperms & 0777)
656             if 'unix.uid' in facts:
657                 uid = 'unix.uid=%s;' % node.uuser
658             if 'unix.gid' in facts:
659                 gid = 'unix.gid=%s;' % node.ugroup
660             # We provide unique fact (see RFC-3659, chapter 7.5.2) on
661             # posix platforms only; we get it by mixing st_dev and
662             # st_ino values which should be enough for granting an
663             # uniqueness for the file listed.
664             # The same approach is used by pure-ftpd.
665             # Implementors who want to provide unique fact on other
666             # platforms should use some platform-specific method (e.g.
667             # on Windows NTFS filesystems MTF records could be used).
668             # if 'unique' in facts: todo
669             #    unique = "unique=%x%x;" %(st.st_dev, st.st_ino)
670             path = node.path
671             if isinstance (path, (list, tuple)):
672                 path = path[-1]
673             path=_to_decode(path)
674             yield "%s%s%s%s%s%s%s%s%s %s\r\n" %(type, size, perm, modify, create,
675                                                 mode, uid, gid, unique, path)
676
677 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
678