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