document usage with trac and http basic auth
[wikifs.git] / wikifuse.py
blobdab3a51695b98ded016761c5ba8a5fb36b2dba9d
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
4 # Copyright 2006, 2008, 2009 Nedko Arnaudov <nedko@arnaudov.name>
5 # You can use this software for any purpose you want
6 # Use it at your own risk
7 # You are allowed to modify the code
8 # You are disallowed to remove author credits from header
10 # FUSE filesystem for wiki
12 # Uses XML-RPC for accessing wiki
13 # Documentation here: http://www.jspwiki.org/Wiki.jsp?page=WikiRPCInterface2
15 # Requires:
16 # FUSE python bindings (0.2 or later), available from FUSE project http://fuse.sourceforge.net/
17 # xmlrpclib, available since python 2.2
19 # Tested with:
20 # Editors: xemacs, vi
21 # FUSE lkm: one comming with 2.6.15, 2.6.23.1
22 # FUSE usermode: 2.5.2, 2.7.3
23 # Python: 2.4.2, 2.5.1
24 # Wiki engines: MoinMoinWiki 1.5.0, 1.6.3
26 # Expected to work with:
27 # Every editor (60%)
28 # Python >= 2.2 (99%)
29 # FUSE > 2.5.2 (70%)
30 # MoinMoinWiki > 1.5.0 (90%)
32 # If you improve this code, send a modified version to author please
34 ########################################################################################
36 example_config = """
37 # example configuration file for wikifuse
39 # wikis is array of dictionaries
40 # each dictionary represents one wiki server
42 # keys:
43 # * name - REQUIRED name of the wiki server, will be used as directory name
44 # * xmlrpc_url - REQUIRED xml-rpc url of the wiki server
45 # * auth - OPTIONAL authentication method.
47 # authentication methods and related keys:
48 # * 'moin_cookie' required keys:
49 # * user_id - moin internal moin user id. Look at your data/users directory in wiki instance to get exact value
50 # * 'moin_auth' required keys:
51 # * username - your moin username
52 # * password - your moin password
54 wikis = [
56 'name': 'example.com',
57 'xmlrpc_url': 'http://wiki.example.com/moin.cgi?action=xmlrpc2',
58 'auth': 'moin_cookie',
59 'user_id': '1111111111.22.3333'
62 'name': 'wiki_anon',
63 'xmlrpc_url': 'http://wiki.example.com/moin.cgi/?action=xmlrpc2',
66 'name': 'trac_http_basic',
67 'xmlrpc_url': 'https://user:pass@wiki.example.com/login/xmlrpc',
70 'name': 'example.org',
71 'xmlrpc_url': 'http://wiki.example.org/wiki/moin.cgi/?action=xmlrpc2',
72 'auth': 'moin_auth',
73 'username': 'foo',
74 'password': 'bar'
77 """
79 # Verbosity
81 # 0 - silent
82 # 1 - errors
83 # 2 - basic
84 # 3 - debug
85 verbosity = 2
87 import xmlrpclib
88 import fuse
89 import os, sys
90 import xmlrpclib
91 import types
92 from errno import *
93 from stat import *
94 import locale
95 import re
97 fuse.fuse_python_api = (0, 2)
99 out_charset = locale.getpreferredencoding()
100 if out_charset == None:
101 out_charset = "utf-8"
102 page_coding = "utf-8"
104 #print("Using charset %s for stdout" % out_charset)
106 def printex(str):
107 if type(str) != types.StringType and type(str) != types.UnicodeType:
108 raise "trying to print non-string type %s" % type(str)
110 try:
111 print str.encode(out_charset, "replace")
112 except UnicodeDecodeError, e:
113 print "---------------- cannot print: %s" % e
114 except:
115 print "---------------- cannot print: %s" % sys.exc_info()[0]
117 def log(str):
118 printex(str)
120 def log_error(str):
121 if verbosity >= 1:
122 log(str)
124 def log_basic(str):
125 if verbosity >= 2:
126 log(str)
128 def log_debug(str):
129 if verbosity >= 3:
130 log(str)
132 class WikiServerProxy:
133 def __init__(self, url, transport=None, prefix=""):
134 # if available, strip username and password from url, because the self.url string is used in prints
135 m = re.match(r"(https?://)([^:]+):([^@]+)@(.*)", url)
136 if m != None:
137 self.url = m.group(1) + m.group(4)
138 else:
139 self.url = url;
141 if verbosity >= 3:
142 xmlrpc_verbose = 1
143 else:
144 xmlrpc_verbose = 0
146 self.xmlrpc = xmlrpclib.ServerProxy(url, verbose=xmlrpc_verbose, transport=transport)
147 try:
148 print self.url + ": introspection is supported"# + repr(self.xmlrpc.system.listMethods())
149 except xmlrpclib.Fault, f:
150 print self.url + ": introspection is not supported. code=%d (%s)" % (f.faultCode, f.faultString)
152 self.fnprefix = prefix
154 def cleanup(self):
155 pass
157 # xml rpc proxy without authentication
158 class WikiServerProxySimple(WikiServerProxy):
159 def __init__(self, url, prefix=""):
160 WikiServerProxy.__init__(self, url, prefix=prefix)
162 def __getattr__(self, name):
163 #log("__getattr__ " + name)
164 return self.function(self.xmlrpc, self.fnprefix + name)
166 class function:
167 def __init__(self, xmlrpc, name):
168 self.xmlrpc = xmlrpc
169 self.name = name
171 def __call__(self, *args):
172 #log("proxy call %s%s" % (self.name, repr(args)))
173 func = self.xmlrpc.__getattr__(self.name)
174 return func(*args)
176 # moin_cookie authentication (MoinMoinWiki)
178 # For this to work change server check for self.request.user.trusted to check for self.request.user.valid
179 # The check is made in wikirpc.py, near line 362
181 # user_id is simply internal moin user id
182 # Look at your data/users directory in wiki instance to get exact value
184 class WikiServerProxyMoinCookie(WikiServerProxy):
185 def __init__(self, url, user_id, prefix=""):
186 if url.startswith("https"):
187 transport = self.safeTransport(user_id)
188 else:
189 transport = self.transport(user_id)
190 WikiServerProxy.__init__(self, url, transport=transport, prefix=prefix)
192 def __getattr__(self, name):
193 #log("__getattr__ " + name)
194 return self.function(self.xmlrpc, self.fnprefix + name)
196 class function:
197 def __init__(self, xmlrpc, name):
198 self.xmlrpc = xmlrpc
199 self.name = name
201 def __call__(self, *args):
202 #log("proxy call %s%s" % (self.name, repr(args)))
203 func = self.xmlrpc.__getattr__(self.name)
204 return func(*args)
206 class transport(xmlrpclib.Transport):
207 def __init__(self, user_id):
208 xmlrpclib.Transport.__init__(self)
209 self.user_id = user_id
211 def get_host_info(self, host):
212 host, extra_headers, x509 = xmlrpclib.Transport.get_host_info(self, host)
214 if extra_headers == None:
215 extra_headers = []
216 extra_headers.append(("Cookie", "MOIN_ID=" + self.user_id))
218 return host, extra_headers, x509
220 class safeTransport(xmlrpclib.SafeTransport):
221 def __init__(self, user_id):
222 xmlrpclib.SafeTransport.__init__(self)
223 self.user_id = user_id
225 def get_host_info(self, host):
226 host, extra_headers, x509 = xmlrpclib.SafeTransport.get_host_info(self, host)
228 if extra_headers == None:
229 extra_headers = []
230 extra_headers.append(("Cookie", "MOIN_ID=" + self.user_id))
232 return host, extra_headers, x509
234 # MoinMoinWiki xmlrpc authentication
236 class WikiServerProxyMoinAuth(WikiServerProxy):
237 def __init__(self, url, username, password, prefix=""):
238 WikiServerProxy.__init__(self, url, prefix=prefix)
240 self.username = username
241 self.password = password
242 self.token = None
243 self.check_token()
245 def __getattr__(self, name):
246 #log("__getattr__ " + name)
247 self.check_token()
248 return self.function(self.xmlrpc, self.fnprefix + name, self.token)
250 def cleanup(self):
251 if self.token:
252 log_basic("Deleting moin auth token for '%s'" % self.url)
253 assert self.xmlrpc.deleteAuthToken(self.token) == 'SUCCESS'
255 def check_token(self):
256 # Verify that the token is valid by using it
257 # and checking that the result is 'SUCCESS'.
258 # The token should be valid for 15 minutes.
260 if self.token:
261 try:
262 self.xmlrpc.applyAuthToken(self.token)
263 except:
264 self.token = None
266 if not self.token:
267 # refresh token
268 log_basic("Getting new moin auth token for '%s'" % self.url)
269 self.token = self.xmlrpc.getAuthToken(self.username, self.password)
271 if self.xmlrpc.applyAuthToken(self.token) != 'SUCCESS':
272 log_error("Invalid username/password when authenticating to '%s' (%s/%s)" % (url, username, password))
274 class function:
275 def __init__(self, xmlrpc, name, token):
276 self.xmlrpc = xmlrpc
277 self.name = name
278 self.token = token
280 def __call__(self, *args):
281 #log("proxy call %s%s" % (self.name, repr(args)))
283 # build a multicall object that
284 mcall = xmlrpclib.MultiCall(self.xmlrpc)
286 # first applies the token and
287 mcall.applyAuthToken(self.token)
289 # then call the real function
290 mcall.__getattr__(self.name)(*args)
292 # now execute the multicall
293 results = mcall()
295 #log_basic(results[0])
297 try:
298 ret = results[1]
299 except xmlrpclib.Fault, f:
300 ret = {'faultCode': f.faultCode, 'faultString': f.faultString}
302 return ret
304 class MyStat(fuse.Stat):
305 def __init__(self):
306 self.st_mode = 0
307 self.st_ino = 0
308 self.st_dev = 0
309 self.st_nlink = 0
310 self.st_uid = 0
311 self.st_gid = 0
312 self.st_size = 0
313 self.st_atime = 0
314 self.st_mtime = 0
315 self.st_ctime = 0
317 class WikiServer:
318 def __init__(self, name, proxy, put2=False):
319 self.name = name
321 self.wikiproxy = proxy
323 self.put2 = put2
325 info = "name: %s\n" % name
326 info += "url: %s\n" % proxy.url
327 info += "proxy: %s\n" % proxy.__class__.__name__
329 # test rpc
330 #log("%10s: %s (%s)" % (name, self.wikiproxy.WhoAmI(), proxy.url))
332 try:
333 whoami = self.wikiproxy.WhoAmI()
334 except xmlrpclib.Fault, e:
335 whoami = e
336 except:
337 whoami = sys.exc_info()[0]
339 info += "whoami: %s\n" % whoami
341 try:
342 version = self.wikiproxy.getRPCVersionSupported()
343 except xmlrpclib.Fault, e:
344 version = e
345 except:
346 version = sys.exc_info()[0]
348 info += "wiki xml rpc version: %s\n" % version
350 self.pages = {}
351 self.virt_pages = {}
352 self.all = [unicode(x).encode(page_coding)
353 for x in self.wikiproxy.getAllPages()]
354 info += "pages: %s\n%s" % (len(self.all),"\n".join(self.all))
356 self.virt_pages['_'] = info
357 #self.virt_pages['cyr'] = u"кириÐ��»Ð¸Ñ Ð��°"
359 def cleanup(self):
360 self.wikiproxy.cleanup()
362 def readdir(self, offset):
363 yield fuse.Direntry('.')
364 yield fuse.Direntry('..')
366 for page in self.virt_pages.iterkeys():
367 yield fuse.Direntry(page)
369 all = self.all
370 for page in all:
371 page = page.replace("/","%%")
372 if page.find('/') == -1:
373 yield fuse.Direntry(page)
375 def getattr(self, path):
376 log_basic("getattr \"%s\"" % path)
378 st = MyStat()
379 st.st_nlink = 1
381 st.st_mode = S_IFREG | 0666
383 if self.virt_pages.has_key(path):
384 st.st_size = len(self.virt_pages[path])
385 return st
387 if self.pages.has_key(path):
388 st.st_size = self.pages[path]['size']
389 return st
391 if path.replace("%%","/") not in self.all:
392 log_basic("not in all")
393 return -ENOENT
395 info = self.wikiproxy.getPageInfo(path.replace("%%","/"))
396 if info.has_key('faultCode'):
397 log_error("getPageInfo(%s) failed" % path + repr(info))
398 return -ENOENT
400 data = self.wikiproxy.getPage(path.replace("%%","/"))
401 data = unicode(data).encode(out_charset)
402 st.st_size = len(data)
404 return st
406 def open(self, path, flags):
407 log_basic("open \"%s\" flags %u" % (path, flags))
409 if not self.pages.has_key(path):
410 self.pages[path] = {}
412 if self.virt_pages.has_key(path):
413 data = self.virt_pages[path]
414 self.pages[path]['data'] = data
415 self.pages[path]['size'] = len(data)
416 self.pages[path]['modified'] = False
417 return 0
419 data = self.wikiproxy.getPage(path.replace("%%","/"))
420 data = unicode(data).encode(out_charset)
421 if self.pages[path].has_key('size'):
422 size = self.pages[path]['size']
423 if size != len(data):
424 log_basic("Truncating to %u bytes" % size)
425 if size == 0:
426 data = ""
427 else:
428 data = data[size:]
429 self.pages[path]['modified'] = True
430 self.pages[path]['data'] = data
431 log_debug("\"%s\"" % data)
432 else:
433 size = len(data)
434 log_basic("Updating size of '%s' to %u bytes" % (path, size))
435 self.pages[path]['modified'] = False
436 self.pages[path]['data'] = data
437 self.pages[path]['size'] = size
438 return 0
440 def read(self, path, length, offset):
441 log_basic("read \"%s\" %u bytes, from offset %u" % (path, length, offset))
443 if offset + length > self.pages[path]['size']:
444 length = self.pages[path]['size'] - offset
446 if length == 0:
447 data = ""
448 else:
449 data = self.pages[path]['data'][offset:offset+length]
450 log_debug("\"%s\"" % data)
451 log_basic("data length: %u bytes" % len(data))
452 log_basic("type: %s" % type(data))
453 return data
455 def write(self, path, buf, offset):
456 log_basic("write \"%s\" %u bytes, to offset %u" % (path, len(buf), offset))
457 size = len(buf)
458 pre = self.pages[path]['data'][:offset]
459 post = self.pages[path]['data'][offset+size:]
460 data = pre + buf + post
461 self.pages[path]['size'] = len(data)
462 log_debug("\"%s\"" % data)
463 self.pages[path]['data'] = data
464 self.pages[path]['modified'] = True
465 return size
467 def truncate(self, path, size):
468 log_basic("truncate \"%s\" %u bytes" % (path, size))
470 if not self.pages.has_key(path):
471 self.pages[path] = {}
472 else:
473 if size > self.pages[path]['size']:
474 return -EINVAL
475 self.pages[path]['modified'] = True
476 self.pages[path]['size'] = size
477 if self.pages[path].has_key('data'):
478 self.pages[path]['data'] = self.pages[path]['data'][size:]
479 return 0
481 def release(self, path, flags):
482 log_basic("release \"%s\"" % path)
484 ret = self.fsync_do(path)
485 if ret == 0:
486 del self.pages[path]['data']
488 return ret
490 def fsync(self, path, isfsyncfile):
491 log_basic("fsync: path=%s, isfsyncfile=%s" % (path, isfsyncfile))
492 return self.fsync_do(path)
494 def fsync_do(self, path):
495 if self.pages.has_key(path):
496 if self.pages[path]['modified']:
497 log_basic("PUT PAGE")
498 log_debug("\"%s\"" % self.pages[path]['data'])
499 data = unicode(self.pages[path]['data'], out_charset)
500 safepath = path.replace("%%","/")
501 if self.put2:
502 ret = self.wikiproxy.putPage(safepath, data)
503 else:
504 ret = self.wikiproxy.putPage(safepath, data, {})
505 if type(ret) == types.BooleanType and ret == True:
506 self.pages[path]['modified'] = False
507 return 0
508 else:
509 log_error("putPage(%s) failed" % path + repr(ret))
510 return -EIO
512 class WikiFS(fuse.Fuse):
514 def __init__(self, servers, *args, **kw):
515 fuse.Fuse.__init__(self, *args, **kw)
517 #log_basic("mountpoint: %s" % repr(self.mountpoint))
518 #log_basic("unnamed mount options: %s" % self.optlist)
519 #log_basic("named mount options: %s" % self.optdict)
521 # do stuff to set up your filesystem here, if you want
522 #thread.start_new_thread(self.mythread, ())
524 self.servers = servers
526 def mythread(self):
529 The beauty of the FUSE python implementation is that with the python interp
530 running in foreground, you can have threads
532 log_basic("mythread: started")
533 #while 1:
534 # time.sleep(120)
535 # log_basic("mythread: ticking")
537 flags = 1
539 def getattr(self, path):
540 log_basic("getattr \"%s\"" % path)
542 st = MyStat()
544 st.st_nlink = 2
546 if path == "/" or (
547 path.count("/") == 1 and
548 path[1:] in [server.name for server in self.servers] ):
549 st.st_mode = S_IFDIR | 0700
550 return st
552 server, subname = self.get_subname(path)
553 if subname:
554 return server.getattr(subname);
556 return -EINVAL
558 def readlink(self, path):
559 log_basic("readlink")
560 return -ENOSYS
562 def readdir_top(self, path, offset):
563 yield fuse.Direntry('.')
564 yield fuse.Direntry('..')
566 for server in self.servers:
567 yield fuse.Direntry(server.name)
569 def readdir(self, path, offset):
570 log_basic("readdir(path='%s', offset=%u)" % (path, offset))
572 if path == '/':
573 return self.readdir_top(path, offset)
575 server_name = path[1:]
576 for server in self.servers:
577 if server.name == server_name:
578 return server.readdir(offset)
580 return -EINVAL
582 def unlink(self, path):
583 log_basic("unlink")
584 return -ENOSYS
585 def rmdir(self, path):
586 log_basic("rmdir")
587 return -ENOSYS
588 def symlink(self, path, path1):
589 log_basic("symlink")
590 return -ENOSYS
591 def rename(self, path, path1):
592 log_basic("rename")
593 return -ENOSYS
594 def link(self, path, path1):
595 log_basic("link")
596 return -ENOSYS
597 def chmod(self, path, mode):
598 log_basic("chmod")
599 return -ENOSYS
600 def chown(self, path, user, group):
601 log_basic("chown")
602 return -ENOSYS
604 def truncate(self, path, size):
605 log_basic("truncate \"%s\" %u bytes" % (path, size))
607 server, subname = self.get_subname(path)
608 if subname:
609 return server.truncate(subname, size);
611 return -EINVAL
613 def mknod(self, path, mode, dev):
614 log_basic("mknod")
615 return -ENOSYS
616 def mkdir(self, path, mode):
617 log_basic("mkdir")
618 return -ENOSYS
619 def utime(self, path, times):
620 log_basic("utime")
621 return -ENOSYS
623 def get_subname(self, path):
624 for server in self.servers:
625 prefix = "/" + server.name + "/"
626 if path.startswith(prefix):
627 return (server, path[len(prefix):])
629 return (None, None)
631 def open(self, path, flags):
632 log_basic("open \"%s\" flags %u" % (path, flags))
634 server, subname = self.get_subname(path)
635 if subname:
636 return server.open(subname, flags);
638 return -EINVAL
640 #def opendir(self, path):
641 # log_basic("opendir \"%s\"" % path)
642 # return WikiDir()
644 def read(self, path, length, offset):
645 log_basic("read \"%s\" %u bytes, from offset %u" % (path, length, offset))
647 server, subname = self.get_subname(path)
648 if subname:
649 return server.read(subname, length, offset);
651 return -EINVAL
653 def write(self, path, buf, offset):
654 log_basic("write \"%s\" %u bytes, to offset %u" % (path, len(buf), offset))
656 server, subname = self.get_subname(path)
657 if subname:
658 return server.write(subname, buf, offset);
660 return -EINVAL
662 def release(self, path, flags):
663 log_basic("release \"%s\"" % path)
665 server, subname = self.get_subname(path)
666 if subname:
667 return server.release(subname, flags);
669 return -EINVAL
671 def fsync(self, path, isfsyncfile):
672 log_basic("fsync: path=%s, isfsyncfile=%s" % (path, isfsyncfile))
674 server, subname = self.get_subname(path)
675 if subname:
676 return server.fsync(subname, isfsyncfile);
678 return -EINVAL
680 def main(self):
681 fuse.Fuse.main(self)
683 # Read config file
684 old_path = sys.path
685 sys.path = [".",os.environ['HOME'] + "/.config/wikifuse"]
686 try:
687 import config
688 except:
689 log_error("No configuration file found in '%s'" % sys.path[0])
690 #log_error(repr(sys.exc_info()[0]))
691 log_error("Example configuration file (save it as %s/config.py):" % sys.path[0])
692 log_error(example_config)
693 sys.exit(1)
695 sys.path = old_path
697 def main():
698 servers = []
699 for wiki in config.wikis:
700 proxy = None
702 if wiki.has_key('use_prefix'):
703 use_prefix = wiki['use_prefix']
704 else:
705 use_prefix = True # default is to follow the spec
707 if use_prefix:
708 prefix = "wiki."
709 else:
710 prefix = ""
712 if wiki.has_key('put2'):
713 put2 = wiki['put2']
714 else:
715 put2 = False # default is to follow the spec
717 if wiki.has_key('auth'):
718 if wiki['auth'] == 'moin_cookie':
719 proxy = WikiServerProxyMoinCookie(wiki['xmlrpc_url'], wiki['user_id'], prefix=prefix)
720 elif wiki['auth'] == 'moin_auth':
721 proxy = WikiServerProxyMoinAuth(wiki['xmlrpc_url'], wiki['username'], wiki['password'], prefix=prefix)
722 else:
723 log_error("Unknown authentication method '%s'" % wiki['auth'])
724 continue
725 else:
726 proxy = WikiServerProxySimple(wiki['xmlrpc_url'], prefix=prefix)
727 servers.append(WikiServer(wiki['name'], proxy, put2=put2))
729 fs = WikiFS(servers,version="%prog " + fuse.__version__,dash_s_do='setsingle')
730 #fs.multithreaded = 1
731 fs.parse(errex=1)
732 fs.main()
734 for server in servers:
735 server.cleanup()
737 if __name__ == '__main__':
738 main()