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