anki-sync-server/AnkiServer/sync_app.py
2013-08-14 18:47:47 +07:00

344 lines
11 KiB
Python

from ConfigParser import SafeConfigParser
from webob.dec import wsgify
from webob.exc import *
from webob import Response
import sqlite3
import hashlib
import AnkiServer
import anki
from anki.sync import LocalServer, MediaSyncer
try:
import simplejson as json
except ImportError:
import json
import os
class SyncCollectionHandler(LocalServer):
operations = ['meta', 'applyChanges', 'start', 'chunk', 'applyChunk', 'sanityCheck2', 'finish']
def __init__(self, col):
LocalServer.__init__(self, col)
def applyChanges(self, changes):
#self.lmod, lscm, self.maxUsn, lts, dummy = self.meta()
# TODO: how should we set this value?
#self.lnewer = 1
result = LocalServer.applyChanges(self, changes)
#self.prepareToChunk()
return result
#def chunk(self, ):
# self.prepareToChunk()
# return LocalServer.chunk()
class SyncMediaHandler(MediaSyncer):
operations = ['remove', 'files', 'addFiles', 'mediaSanity']
def __init__(self, col):
MediaSyncer.__init__(self, col)
def files(self, minUsn=0):
import zipfile, StringIO
zipdata, fnames = MediaSyncer.files(self)
# add a _usn element to the zipdata
fd = StringIO.StringIO(zipdata)
zfd = zipfile.ZipFile(fd, "a", compression=zipfile.ZIP_DEFLATED)
zfd.writestr("_usn", str(minUsn + len(fnames)))
zfd.close()
return fd.getvalue()
class SyncUserSession(object):
def __init__(self, name, path, collection_manager):
import time
self.name = name
self.path = path
self.collection_manager = collection_manager
self.version = 0
self.created = time.time()
# make sure the user path exists
if not os.path.exists(path):
os.mkdir(path)
self.collection_handler = None
self.media_handler = None
def get_collection_path(self):
return os.path.realpath(os.path.join(self.path, 'collection.anki2'))
def get_thread(self):
return self.collection_manager.get_collection(self.get_collection_path())
def get_handler_for_operation(self, operation, col):
if operation in SyncCollectionHandler.operations:
cache_name, handler_class = 'collection_handler', SyncCollectionHandler
else:
cache_name, handler_class = 'media_handler', SyncMediaHandler
if getattr(self, cache_name) is None:
setattr(self, cache_name, handler_class(col))
return getattr(self, cache_name)
class SyncApp(object):
valid_urls = SyncCollectionHandler.operations + SyncMediaHandler.operations + ['hostKey', 'upload', 'download', 'getDecks']
def __init__(self, config):
from AnkiServer.thread import getCollectionManager
self.data_root = os.path.abspath(config.get("sync_app", "data_root"))
self.base_url = config.get("sync_app", "base_url")
self.sessions = {}
self.collection_manager = getCollectionManager()
# make sure the base_url has a trailing slash
if len(self.base_url) == 0:
self.base_url = '/'
elif self.base_url[-1] != '/':
self.base_url = base_url + '/'
def authenticate(self, username, password):
"""
Returns True if this username is allowed to connect with this password. False otherwise.
Override this to change how users are authenticated.
"""
return False
def username2dirname(self, username):
"""
Returns the directory name for the given user. By default, this is just the username.
Override this to adjust the mapping between users and their directory.
"""
return username
def generateHostKey(self, username):
"""Generates a new host key to be used by the given username to identify their session.
This values is random."""
import hashlib, time, random, string
chars = string.ascii_letters + string.digits
val = ':'.join([username, str(int(time.time())), ''.join(random.choice(chars) for x in range(8))])
return hashlib.md5(val).hexdigest()
def create_session(self, hkey, username, user_path):
"""Creates, stores and returns a new session for the given hkey and username."""
session = self.sessions[hkey] = SyncUserSession(username, user_path, self.collection_manager)
return session
def load_session(self, hkey):
return self.sessions.get(hkey)
def save_session(self, hkey, session):
pass
def delete_session(self, hkey):
del self.sessions[hkey]
def _decode_data(self, data, compression=0):
import gzip, StringIO
if compression:
buf = gzip.GzipFile(mode="rb", fileobj=StringIO.StringIO(data))
data = buf.read()
buf.close()
# really lame check for JSON
if data[0] == '{' and data[-1] == '}':
data = json.loads(data)
else:
data = {'data': data}
return data
def operation_upload(self, col, data, session):
# TODO: deal with thread pool
fd = open(session.get_collection_path(), 'wb')
fd.write(data)
fd.close()
def operation_download(self, col, data, session):
pass
@wsgify
def __call__(self, req):
print req.path
if req.path.startswith(self.base_url):
url = req.path[len(self.base_url):]
if url not in self.valid_urls:
raise HTTPNotFound()
if url == 'getDecks':
# This is an Anki 1.x client! Tell them to upgrade.
import zlib
return Response(
status='200 OK',
content_type='application/json',
content_encoding='deflate',
body=zlib.compress(json.dumps({'status': 'oldVersion'})))
try:
compression = req.POST['c']
except KeyError:
compression = 0
try:
data = req.POST['data'].file.read()
data = self._decode_data(data, compression)
except KeyError:
data = {}
except ValueError:
# Bad JSON
raise HTTPBadRequest()
print 'data:', data
if url == 'hostKey':
try:
u = data['u']
p = data['p']
except KeyError:
raise HTTPForbidden('Must pass username and password')
if self.authenticate(u, p):
dirname = self.username2dirname(u)
if dirname is None:
raise HTTPForbidden()
hkey = self.generateHostKey(u)
user_path = os.path.join(self.data_root, dirname)
session = self.create_session(hkey, u, user_path)
result = {'key': hkey}
return Response(
status='200 OK',
content_type='application/json',
body=json.dumps(result))
else:
# TODO: do I have to pass 'null' for the client to receive None?
raise HTTPForbidden('null')
# Get and verify the session
try:
hkey = req.POST['k']
except KeyError:
raise HTTPForbidden()
session = self.load_session(hkey)
if session is None:
raise HTTPForbidden()
if url in SyncCollectionHandler.operations + SyncMediaHandler.operations:
# 'meta' passes the SYNC_VER but it isn't used in the handler
if url == 'meta' and data.has_key('v'):
session.version = data['v']
del data['v']
# Create a closure to run this operation inside of the thread allocated to this collection
def runFunc(col):
handler = session.get_handler_for_operation(url, col)
func = getattr(handler, url)
result = func(**data)
handler.col.save()
return result
runFunc.func_name = url
# Send to the thread to execute
thread = session.get_thread()
result = thread.execute(runFunc)
# If it's a complex data type, we convert it to JSON
if type(result) not in (str, unicode):
result = json.dumps(result)
if url == 'finish':
self.delete_session(hkey)
return Response(
status='200 OK',
content_type='application/json',
body=result)
elif url in ('upload', 'download'):
if url == 'upload':
func = self.operation_upload
else:
func = self.operation_download
thread = session.get_thread()
thread.execute(self.operation_upload, [data['data'], session])
return Response(
status='200 OK',
content_type='text/plain',
body='OK')
# This was one of our operations but it didn't get handled... Oops!
raise HTTPInternalServerError()
return Response(status='200 OK', content_type='text/plain', body='Anki Sync Server')
class DatabaseAuthSyncApp(SyncApp):
def __init__(self, config):
SyncApp.__init__(self, config)
self.auth_db_path = os.path.abspath(config.get("sync_app", "auth_db_path"))
def authenticate(self, username, password):
"""Returns True if this username is allowed to connect with this password. False otherwise."""
conn = sqlite3.connect(self.auth_db_path)
cursor = conn.cursor()
param = (username,)
cursor.execute("SELECT hash FROM auth WHERE user=?", param)
db_ret = cursor.fetchone()
conn.close()
if db_ret != None:
db_hash = str(db_ret[0])
salt = db_hash[-16:]
hashobj = hashlib.sha256()
hashobj.update(username+password+salt)
return (db_ret != None and hashobj.hexdigest()+salt == db_hash)
def main():
from wsgiref.simple_server import make_server
from AnkiServer.thread import shutdown
config = SafeConfigParser()
config.read("production.ini")
ankiserver = DatabaseAuthSyncApp(config)
httpd = make_server('', config.getint("sync_app", "port"), ankiserver)
try:
print "Starting..."
httpd.serve_forever()
except KeyboardInterrupt:
print "Exiting ..."
finally:
shutdown()
if __name__ == '__main__': main()