Document, calendar: Patch code from trunk-xrg branch
[odoo/odoo.git] / addons / document_ftp / ftpserver / abstracted_fs.py
1 # -*- encoding: utf-8 -*-
2
3 import os
4 import time
5 from tarfile import filemode
6 import logging
7 import errno
8
9 import glob
10 import fnmatch
11
12 import pooler
13 import netsvc
14
15 from service import security
16 from osv import osv
17 from document.nodes import get_node_context
18
19 def _get_month_name(month):
20     month=int(month)
21     if month==1:return 'Jan'
22     elif month==2:return 'Feb'
23     elif month==3:return 'Mar'
24     elif month==4:return 'Apr'
25     elif month==5:return 'May'
26     elif month==6:return 'Jun'
27     elif month==7:return 'Jul'
28     elif month==8:return 'Aug'
29     elif month==9:return 'Sep'
30     elif month==10:return 'Oct'
31     elif month==11:return 'Nov'
32     elif month==12:return 'Dec'
33
34 from ftpserver import _to_decode, _to_unicode
35
36
37 class abstracted_fs(object):
38     """A class used to interact with the file system, providing a high
39     level, cross-platform interface compatible with both Windows and
40     UNIX style filesystems.
41
42     It provides some utility methods and some wraps around operations
43     involved in file creation and file system operations like moving
44     files or removing directories.
45
46     Instance attributes:
47      - (str) root: the user home directory.
48      - (str) cwd: the current working directory.
49      - (str) rnfr: source file to be renamed.
50
51     """
52
53     def __init__(self):
54         self.root = None
55         self.cwd = '/'
56         self.cwd_node = None
57         self.rnfr = None
58         self._log = logging.getLogger('FTP.fs')
59
60     # Ok
61     def db_list(self):
62         """Get the list of available databases, with FTPd support
63         """
64         s = netsvc.ExportService.getService('db')
65         result = s.exp_list(document=True)
66         self.db_name_list = []
67         for db_name in result:
68             db, cr = None, None
69             try:
70                 try:
71                     db = pooler.get_db_only(db_name)
72                     cr = db.cursor()
73                     cr.execute("SELECT 1 FROM pg_class WHERE relkind = 'r' AND relname = 'ir_module_module'")
74                     if not cr.fetchone():
75                         continue
76
77                     cr.execute("SELECT id FROM ir_module_module WHERE name = 'document_ftp' AND state IN ('installed', 'to install', 'to upgrade') ")
78                     res = cr.fetchone()
79                     if res and len(res):
80                         self.db_name_list.append(db_name)
81                     cr.commit()
82                 except Exception:
83                     self._log.warning('Cannot use db "%s"', db_name)
84             finally:
85                 if cr is not None:
86                     cr.close()
87         return self.db_name_list
88
89     def ftpnorm(self, ftppath):
90         """Normalize a "virtual" ftp pathname (tipically the raw string
91         coming from client).
92
93         Pathname returned is relative!.
94         """
95         p = os.path.normpath(ftppath)
96         # normalize string in a standard web-path notation having '/'
97         # as separator. xrg: is that really in the spec?
98         p = p.replace("\\", "/")
99         # os.path.normpath supports UNC paths (e.g. "//a/b/c") but we
100         # don't need them.  In case we get an UNC path we collapse
101         # redundant separators appearing at the beginning of the string
102         while p[:2] == '//':
103             p = p[1:]
104         if p == '.':
105             return ''
106         return p
107
108     def get_cwd(self):
109         """ return the cwd, decoded in utf"""
110         return _to_decode(self.cwd)
111
112     def ftp2fs(self, path_orig, data):
113         raise DeprecationWarning()
114
115     def fs2ftp(self, node):
116         """ Return the string path of a node, in ftp form
117         """
118         res='/'
119         if node:
120             paths = node.full_path()
121             res = '/' + node.context.dbname + '/' +  \
122                 _to_decode(os.path.join(*paths))
123
124         return res
125
126     def validpath(self, path):
127         """Check whether the path belongs to user's home directory.
128         Expected argument is a datacr tuple
129         """
130         # TODO: are we called for "/" ?
131         return isinstance(path, tuple) and path[1] and True or False
132
133     # --- Wrapper methods around open() and tempfile.mkstemp
134
135     def create(self, datacr, objname, mode):
136         """ Create a children file-node under node, open it
137             @return open node_descriptor of the created node
138         """
139         objname = _to_unicode(objname)
140         cr , node, rem = datacr
141         try:
142             child = node.child(cr, objname)
143             if child:
144                 if child.type not in ('file','content'):
145                     raise OSError(1, 'Operation not permited.')
146
147                 ret = child.open_data(cr, mode)
148                 cr.commit()
149                 return ret
150         except EnvironmentError:
151             raise
152         except Exception,e:
153             self._log.exception('Cannot locate item %s at node %s', objname, repr(node))
154             pass
155
156         try:
157             child = node.create_child(cr, objname, data=None)
158             ret = child.open_data(cr, mode)
159             cr.commit()
160             return ret
161         except EnvironmentError:
162             raise
163         except Exception:
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         try:
173             res = node.open_data(cr, mode)
174             cr.commit()
175         except TypeError:
176             raise IOError(errno.EINVAL, "No data")
177         return res
178
179     # ok, but need test more
180
181     def mkstemp(self, suffix='', prefix='', dir=None, mode='wb'):
182         """A wrap around tempfile.mkstemp creating a file with a unique
183         name.  Unlike mkstemp it returns an object with a file-like
184         interface.
185         """
186         raise NotImplementedError # TODO
187
188         text = not 'b' in mode
189         # for unique file , maintain version if duplicate file
190         if dir:
191             cr = dir.cr
192             uid = dir.uid
193             pool = pooler.get_pool(node.context.dbname)
194             object=dir and dir.object or False
195             object2=dir and dir.object2 or False
196             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)])
197             if len(res):
198                 pre = prefix.split('.')
199                 prefix=pre[0] + '.v'+str(len(res))+'.'+pre[1]
200         return self.create(dir,suffix+prefix,text)
201
202
203
204     # Ok
205     def chdir(self, datacr):
206         if (not datacr) or datacr == (None, None, None):
207             self.cwd = '/'
208             self.cwd_node = None
209             return None
210         if not datacr[1]:
211             raise OSError(1, 'Operation not permitted')
212         if datacr[1].type not in  ('collection','database'):
213             raise OSError(2, 'Path is not a directory')
214         self.cwd = '/'+datacr[1].context.dbname + '/'
215         self.cwd += '/'.join(datacr[1].full_path())
216         self.cwd_node = datacr[1]
217
218     # Ok
219     def mkdir(self, datacr, basename):
220         """Create the specified directory."""
221         cr, node, rem = datacr or (None, None, None)
222         if not node:
223             raise OSError(1, 'Operation not permited.')
224
225         try:
226             basename =_to_unicode(basename)
227             cdir = node.create_child_collection(cr, basename)
228             self._log.debug("Created child dir: %r", cdir)
229             cr.commit()
230         except Exception:
231             self._log.exception('Cannot create dir "%s" at node %s', basename, repr(node))
232             raise OSError(1, 'Operation not permited.')
233
234     def close_cr(self, data):
235         if data and data[0]:
236             data[0].close()
237         return True
238
239     def get_cr(self, pathname):
240         raise DeprecationWarning()
241
242     def get_crdata(self, line, mode='file'):
243         """ Get database cursor, node and remainder data, for commands
244
245         This is the helper function that will prepare the arguments for
246         any of the subsequent commands.
247         It returns a tuple in the form of:
248         @code        ( cr, node, rem_path=None )
249
250         @param line An absolute or relative ftp path, as passed to the cmd.
251         @param mode A word describing the mode of operation, so that this
252                     function behaves properly in the different commands.
253         """
254         path = self.ftpnorm(line)
255         if self.cwd_node is None:
256             if not os.path.isabs(path):
257                 path = os.path.join(self.root, path)
258
259         if path == '/' and mode in ('list', 'cwd'):
260             return (None, None, None )
261
262         path = _to_unicode(os.path.normpath(path)) # again, for '/db/../ss'
263         if path == '.': path = ''
264
265         if os.path.isabs(path) and self.cwd_node is not None \
266                 and path.startswith(self.cwd):
267             # make relative, so that cwd_node is used again
268             path = path[len(self.cwd):]
269             if path.startswith('/'):
270                 path = path[1:]
271
272         p_parts = path.split('/') # hard-code the unix sep here, by spec.
273
274         assert '..' not in p_parts
275
276         rem_path = None
277         if mode in ('create',):
278             rem_path = p_parts[-1]
279             p_parts = p_parts[:-1]
280
281         if os.path.isabs(path):
282             # we have to start from root, again
283             while p_parts and p_parts[0] == '':
284                 p_parts = p_parts[1:]
285             # self._log.debug("Path parts: %r ", p_parts)
286             if not p_parts:
287                 raise IOError(errno.EPERM, 'Cannot perform operation at root dir')
288             dbname = p_parts[0]
289             if dbname not in self.db_list():
290                 raise IOError(errno.ENOENT,'Invalid database path: %s' % dbname)
291             try:
292                 db = pooler.get_db(dbname)
293             except Exception:
294                 raise OSError(1, 'Database cannot be used.')
295             cr = db.cursor()
296             try:
297                 uid = security.login(dbname, self.username, self.password)
298             except Exception:
299                 cr.close()
300                 raise
301             if not uid:
302                 cr.close()
303                 raise OSError(2, 'Authentification Required.')
304             n = get_node_context(cr, uid, {})
305             node = n.get_uri(cr, p_parts[1:])
306             return (cr, node, rem_path)
307         else:
308             # we never reach here if cwd_node is not set
309             if p_parts and p_parts[-1] == '':
310                 p_parts = p_parts[:-1]
311             cr, uid = self.get_node_cr_uid(self.cwd_node)
312             if p_parts:
313                 node = self.cwd_node.get_uri(cr, p_parts)
314             else:
315                 node = self.cwd_node
316             if node is False and mode not in ('???'):
317                 cr.close()
318                 raise IOError(errno.ENOENT, 'Path does not exist')
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         node.rmcol(cr)
367         cr.commit()
368
369     def remove(self, datacr):
370         assert datacr[1]
371         if datacr[1].type == 'collection':
372             return self.rmdir(datacr)
373         elif datacr[1].type == 'file':
374             return self.rmfile(datacr)
375         raise OSError(1, 'Operation not permited.')
376
377     def rmfile(self, datacr):
378         """Remove the specified file."""
379         assert datacr[1]
380         cr = datacr[0]
381         datacr[1].rm(cr)
382         cr.commit()
383
384     def rename(self, src, datacr):
385         """ Renaming operation, the effect depends on the src:
386             * A file: read, create and remove
387             * A directory: change the parent and reassign childs to ressource
388         """
389         cr = datacr[0]
390         try:
391             nname = _to_unicode(datacr[2])
392             ret = src.move_to(cr, datacr[1], new_name=nname)
393             # API shouldn't wait for us to write the object
394             assert (ret is True) or (ret is False)
395             cr.commit()
396         except EnvironmentError:
397             raise
398         except Exception:
399             self._log.exception('Cannot rename "%s" to "%s" at "%s"', src, datacr[2], datacr[1])
400             raise OSError(1,'Operation not permited.')
401
402     def stat(self, node):
403         raise NotImplementedError()
404
405     # --- Wrapper methods around os.path.*
406
407     # Ok
408     def isfile(self, node):
409         if node and (node.type in ('file','content')):
410             return True
411         return False
412
413     # Ok
414     def islink(self, path):
415         """Return True if path is a symbolic link."""
416         return False
417
418     def isdir(self, node):
419         """Return True if path is a directory."""
420         if node is None:
421             return True
422         if node and (node.type in ('collection','database')):
423             return True
424         return False
425
426     def getsize(self, datacr):
427         """Return the size of the specified file in bytes."""
428         if not (datacr and datacr[1]):
429             raise IOError(errno.ENOENT, "No such file or directory")
430         if datacr[1].type in ('file', 'content'):
431             return datacr[1].get_data_len(datacr[0]) or 0L
432         return 0L
433
434     # Ok
435     def getmtime(self, datacr):
436         """Return the last modified time as a number of seconds since
437         the epoch."""
438
439         node = datacr[1]
440         if node.write_date or node.create_date:
441             dt = (node.write_date or node.create_date)[:19]
442             result = time.mktime(time.strptime(dt, '%Y-%m-%d %H:%M:%S'))
443         else:
444             result = time.mktime(time.localtime())
445         return result
446
447     # Ok
448     def realpath(self, path):
449         """Return the canonical version of path eliminating any
450         symbolic links encountered in the path (if they are
451         supported by the operating system).
452         """
453         return path
454
455     # Ok
456     def lexists(self, path):
457         """Return True if path refers to an existing path, including
458         a broken or circular symbolic link.
459         """
460         raise DeprecationWarning()
461         return path and True or False
462
463     exists = lexists
464
465     # Ok, can be improved
466     def glob1(self, dirname, pattern):
467         """Return a list of files matching a dirname pattern
468         non-recursively.
469
470         Unlike glob.glob1 raises exception if os.listdir() fails.
471         """
472         names = self.listdir(dirname)
473         if pattern[0] != '.':
474             names = filter(lambda x: x.path[0] != '.', names)
475         return fnmatch.filter(names, pattern)
476
477     # --- Listing utilities
478
479     # note: the following operations are no more blocking
480
481     def get_list_dir(self, datacr):
482         """"Return an iterator object that yields a directory listing
483         in a form suitable for LIST command.
484         """
485         if not datacr:
486             return None
487         elif self.isdir(datacr[1]):
488             listing = self.listdir(datacr)
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