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