doc ftp: enable ftp even before the module is fully installed.
[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         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 EnvironmentError:
398             raise
399         except Exception:
400             self._log.exception('Cannot rename "%s" to "%s" at "%s"', src, datacr[2], datacr[1])
401             raise OSError(1,'Operation not permited.')
402
403     def stat(self, node):
404         raise NotImplementedError()
405
406     # --- Wrapper methods around os.path.*
407
408     # Ok
409     def isfile(self, node):
410         if node and (node.type in ('file','content')):
411             return True
412         return False
413
414     # Ok
415     def islink(self, path):
416         """Return True if path is a symbolic link."""
417         return False
418
419     def isdir(self, node):
420         """Return True if path is a directory."""
421         if node is None:
422             return True
423         if node and (node.type in ('collection','database')):
424             return True
425         return False
426
427     def getsize(self, datacr):
428         """Return the size of the specified file in bytes."""
429         if not (datacr and datacr[1]):
430             raise IOError(errno.ENOENT, "No such file or directory")
431         if datacr[1].type in ('file', 'content'):
432             return datacr[1].get_data_len(datacr[0]) or 0L
433         return 0L
434
435     # Ok
436     def getmtime(self, datacr):
437         """Return the last modified time as a number of seconds since
438         the epoch."""
439
440         node = datacr[1]
441         if node.write_date or node.create_date:
442             dt = (node.write_date or node.create_date)[:19]
443             result = time.mktime(time.strptime(dt, '%Y-%m-%d %H:%M:%S'))
444         else:
445             result = time.mktime(time.localtime())
446         return result
447
448     # Ok
449     def realpath(self, path):
450         """Return the canonical version of path eliminating any
451         symbolic links encountered in the path (if they are
452         supported by the operating system).
453         """
454         return path
455
456     # Ok
457     def lexists(self, path):
458         """Return True if path refers to an existing path, including
459         a broken or circular symbolic link.
460         """
461         raise DeprecationWarning()
462         return path and True or False
463
464     exists = lexists
465
466     # Ok, can be improved
467     def glob1(self, dirname, pattern):
468         """Return a list of files matching a dirname pattern
469         non-recursively.
470
471         Unlike glob.glob1 raises exception if os.listdir() fails.
472         """
473         names = self.listdir(dirname)
474         if pattern[0] != '.':
475             names = filter(lambda x: x.path[0] != '.', names)
476         return fnmatch.filter(names, pattern)
477
478     # --- Listing utilities
479
480     # note: the following operations are no more blocking
481
482     def get_list_dir(self, datacr):
483         """"Return an iterator object that yields a directory listing
484         in a form suitable for LIST command.
485         """
486         if not datacr:
487             return None
488         elif self.isdir(datacr[1]):
489             listing = self.listdir(datacr)
490             return self.format_list(datacr[0], datacr[1], listing)
491         # if path is a file or a symlink we return information about it
492         elif self.isfile(datacr[1]):
493             par = datacr[1].parent
494             return self.format_list(datacr[0], par, [datacr[1]])
495
496     def get_stat_dir(self, rawline, datacr):
497         """Return an iterator object that yields a list of files
498         matching a dirname pattern non-recursively in a form
499         suitable for STAT command.
500
501          - (str) rawline: the raw string passed by client as command
502          argument.
503         """
504         ftppath = self.ftpnorm(rawline)
505         if not glob.has_magic(ftppath):
506             return self.get_list_dir(self.ftp2fs(rawline, datacr))
507         else:
508             basedir, basename = os.path.split(ftppath)
509             if glob.has_magic(basedir):
510                 return iter(['Directory recursion not supported.\r\n'])
511             else:
512                 basedir = self.ftp2fs(basedir, datacr)
513                 listing = self.glob1(basedir, basename)
514                 if listing:
515                     listing.sort()
516                 return self.format_list(basedir, listing)
517
518     def format_list(self, cr, parent_node, listing, ignore_err=True):
519         """Return an iterator object that yields the entries of given
520         directory emulating the "/bin/ls -lA" UNIX command output.
521
522          - (str) basedir: the parent directory node. Can be None
523          - (list) listing: a list of nodes
524          - (bool) ignore_err: when False raise exception if os.lstat()
525          call fails.
526
527         On platforms which do not support the pwd and grp modules (such
528         as Windows), ownership is printed as "owner" and "group" as a
529         default, and number of hard links is always "1". On UNIX
530         systems, the actual owner, group, and number of links are
531         printed.
532
533         This is how output appears to client:
534
535         -rw-rw-rw-   1 owner   group    7045120 Sep 02  3:47 music.mp3
536         drwxrwxrwx   1 owner   group          0 Aug 31 18:50 e-books
537         -rw-rw-rw-   1 owner   group        380 Sep 02  3:40 module.py
538         """
539         for node in listing:
540             perms = filemode(node.unixperms)  # permissions
541             nlinks = 1
542             size = node.content_length or 0L
543             uname = _to_decode(node.uuser)
544             gname = _to_decode(node.ugroup)
545             # stat.st_mtime could fail (-1) if last mtime is too old
546             # in which case we return the local time as last mtime
547             try:
548                 st_mtime = node.write_date or 0.0
549                 if isinstance(st_mtime, basestring):
550                     st_mtime = time.strptime(st_mtime, '%Y-%m-%d %H:%M:%S')
551                 elif isinstance(st_mtime, float):
552                     st_mtime = time.localtime(st_mtime)
553                 mname=_get_month_name(time.strftime("%m", st_mtime ))
554                 mtime = mname+' '+time.strftime("%d %H:%M", st_mtime)
555             except ValueError:
556                 mname=_get_month_name(time.strftime("%m"))
557                 mtime = mname+' '+time.strftime("%d %H:%M")
558             fpath = node.path
559             if isinstance(fpath, (list, tuple)):
560                 fpath = fpath[-1]
561             # formatting is matched with proftpd ls output
562             path=_to_decode(fpath)
563             yield "%s %3s %-8s %-8s %8s %s %s\r\n" %(perms, nlinks, uname, gname,
564                                                      size, mtime, path)
565
566     # Ok
567     def format_mlsx(self, cr, basedir, listing, perms, facts, ignore_err=True):
568         """Return an iterator object that yields the entries of a given
569         directory or of a single file in a form suitable with MLSD and
570         MLST commands.
571
572         Every entry includes a list of "facts" referring the listed
573         element.  See RFC-3659, chapter 7, to see what every single
574         fact stands for.
575
576          - (str) basedir: the absolute dirname.
577          - (list) listing: the names of the entries in basedir
578          - (str) perms: the string referencing the user permissions.
579          - (str) facts: the list of "facts" to be returned.
580          - (bool) ignore_err: when False raise exception if os.stat()
581          call fails.
582
583         Note that "facts" returned may change depending on the platform
584         and on what user specified by using the OPTS command.
585
586         This is how output could appear to the client issuing
587         a MLSD request:
588
589         type=file;size=156;perm=r;modify=20071029155301;unique=801cd2; music.mp3
590         type=dir;size=0;perm=el;modify=20071127230206;unique=801e33; ebooks
591         type=file;size=211;perm=r;modify=20071103093626;unique=801e32; module.py
592         """
593         permdir = ''.join([x for x in perms if x not in 'arw'])
594         permfile = ''.join([x for x in perms if x not in 'celmp'])
595         if ('w' in perms) or ('a' in perms) or ('f' in perms):
596             permdir += 'c'
597         if 'd' in perms:
598             permdir += 'p'
599         type = size = perm = modify = create = unique = mode = uid = gid = ""
600         for node in listing:
601             # type + perm
602             if self.isdir(node):
603                 if 'type' in facts:
604                     type = 'type=dir;'
605                 if 'perm' in facts:
606                     perm = 'perm=%s;' %permdir
607             else:
608                 if 'type' in facts:
609                     type = 'type=file;'
610                 if 'perm' in facts:
611                     perm = 'perm=%s;' %permfile
612             if 'size' in facts:
613                 size = 'size=%s;' % (node.content_length or 0L)
614             # last modification time
615             if 'modify' in facts:
616                 try:
617                     st_mtime = node.write_date or 0.0
618                     if isinstance(st_mtime, basestring):
619                         st_mtime = time.strptime(st_mtime, '%Y-%m-%d %H:%M:%S')
620                     elif isinstance(st_mtime, float):
621                         st_mtime = time.localtime(st_mtime)
622                     modify = 'modify=%s;' %time.strftime("%Y%m%d%H%M%S", st_mtime)
623                 except ValueError:
624                     # stat.st_mtime could fail (-1) if last mtime is too old
625                     modify = ""
626             if 'create' in facts:
627                 # on Windows we can provide also the creation time
628                 try:
629                     st_ctime = node.create_date or 0.0
630                     if isinstance(st_ctime, basestring):
631                         st_ctime = time.strptime(st_ctime, '%Y-%m-%d %H:%M:%S')
632                     elif isinstance(st_mtime, float):
633                         st_ctime = time.localtime(st_ctime)
634                     create = 'create=%s;' %time.strftime("%Y%m%d%H%M%S",st_ctime)
635                 except ValueError:
636                     create = ""
637             # UNIX only
638             if 'unix.mode' in facts:
639                 mode = 'unix.mode=%s;' %oct(node.unixperms & 0777)
640             if 'unix.uid' in facts:
641                 uid = 'unix.uid=%s;' % _to_decode(node.uuser)
642             if 'unix.gid' in facts:
643                 gid = 'unix.gid=%s;' % _to_decode(node.ugroup)
644             # We provide unique fact (see RFC-3659, chapter 7.5.2) on
645             # posix platforms only; we get it by mixing st_dev and
646             # st_ino values which should be enough for granting an
647             # uniqueness for the file listed.
648             # The same approach is used by pure-ftpd.
649             # Implementors who want to provide unique fact on other
650             # platforms should use some platform-specific method (e.g.
651             # on Windows NTFS filesystems MTF records could be used).
652             # if 'unique' in facts: todo
653             #    unique = "unique=%x%x;" %(st.st_dev, st.st_ino)
654             path = node.path
655             if isinstance (path, (list, tuple)):
656                 path = path[-1]
657             path=_to_decode(path)
658             yield "%s%s%s%s%s%s%s%s%s %s\r\n" %(type, size, perm, modify, create,
659                                                 mode, uid, gid, unique, path)
660
661 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4:
662