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