├── weave ├── minimal │ ├── __init__.py │ ├── constants.py │ ├── compat.py │ ├── misc.py │ ├── user.py │ ├── utils.py │ └── storage.py ├── static │ └── scratch.png └── __init__.py ├── MANIFEST.in ├── site ├── setup.png └── index.html ├── .gitignore ├── setup.py ├── LICENSE └── README.md /weave/minimal/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include weave/static/scratch.png 2 | -------------------------------------------------------------------------------- /site/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posativ/weave-minimal/HEAD/site/setup.png -------------------------------------------------------------------------------- /weave/static/scratch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/posativ/weave-minimal/HEAD/weave/static/scratch.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-* 3 | 4 | /.Python 5 | /bin/ 6 | /build/ 7 | /dist/ 8 | /include/ 9 | /lib/ 10 | /share/ 11 | -------------------------------------------------------------------------------- /site/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Firefox Sync Setup with weave-minimal 5 | 6 | 7 | 8 |
9 | 10 | 11 |

See 12 | Instructions on GitHub.

13 |
14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /weave/minimal/constants.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | 3 | WEAVE_INVALID_USER = "3" # Invalid/missing username 4 | WEAVE_INVALID_WRITE = "4" # Attempt to overwrite data that can't be 5 | WEAVE_MALFORMED_JSON = "6" # Json parse failure 6 | WEAVE_MISSING_PASSWORD = "7" # Missing password field 7 | WEAVE_INVALID_WBO = "8" # Invalid Weave Basic Object 8 | WEAVE_WEAK_PASSWORD = "9" # Requested password not strong enough 9 | -------------------------------------------------------------------------------- /weave/minimal/compat.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # 3 | # Copyright 2013 Armin Ronacher . All rights reserved. 4 | # License: BSD Style, 2 clauses -- see LICENSE. 5 | # 6 | # http://lucumr.pocoo.org/2013/5/21/porting-to-python-3-redux/ 7 | 8 | import sys 9 | PY2K = sys.version_info[0] == 2 10 | 11 | if not PY2K: 12 | iterkeys = lambda d: iter(d.keys()) 13 | iteritems = lambda d: iter(d.items()) 14 | else: 15 | iterkeys = lambda d: d.iterkeys() 16 | iteritems = lambda d: d.iteritems() 17 | -------------------------------------------------------------------------------- /weave/minimal/misc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | from werkzeug.wrappers import Response 5 | 6 | 7 | def index(app, environ, request): 8 | return Response("It works!", 200) 9 | 10 | 11 | def captcha_html(app, environ, request, version): 12 | path = environ.get("HTTP_X_SCRIPT_NAME", "/").rstrip("/") 13 | return Response("".join([ 14 | '' 17 | ]), content_type="text/html") 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | from sys import version_info 5 | from setuptools import setup, find_packages 6 | 7 | if version_info < (3, 0): 8 | requires = ['werkzeug>=0.8'] 9 | if version_info < (2, 7): 10 | requires += ["argparse"] 11 | else: 12 | requires = ['werkzeug>=0.9'] 13 | 14 | setup( 15 | name='weave-minimal', 16 | version='1.5', 17 | author='Martin Zimmermann', 18 | author_email='info@posativ.org', 19 | packages=find_packages(), 20 | zip_safe=True, 21 | url='https://github.com/posativ/weave-minimal/', 22 | license='BSD revised', 23 | description='lightweight firefox weave/sync server', 24 | classifiers=[ 25 | "Development Status :: 5 - Production/Stable", 26 | "Topic :: Internet", 27 | "Topic :: Internet :: WWW/HTTP :: HTTP Servers", 28 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 29 | "License :: OSI Approved :: BSD License", 30 | "Programming Language :: Python :: 2.6", 31 | "Programming Language :: Python :: 2.7", 32 | "Programming Language :: Python :: 3.3" 33 | ], 34 | install_requires=requires, 35 | entry_points={ 36 | 'console_scripts': 37 | ['weave-minimal = weave:main'], 38 | }, 39 | ) 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Martin Zimmermann and Contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 21 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | The views and conclusions contained in the software and documentation are 26 | those of the authors and should not be interpreted as representing official 27 | policies, either expressed or implied, of Martin Zimmermann . -------------------------------------------------------------------------------- /weave/minimal/user.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | import os 5 | import sqlite3 6 | 7 | from werkzeug.wrappers import Response 8 | 9 | from weave.minimal.utils import login, BadRequest 10 | from weave.minimal.constants import ( 11 | WEAVE_INVALID_WRITE, WEAVE_MISSING_PASSWORD, 12 | WEAVE_WEAK_PASSWORD, WEAVE_INVALID_USER) 13 | 14 | 15 | @login(['DELETE', 'POST']) 16 | def index(app, environ, request, version, uid): 17 | 18 | # Returns 1 if the uid is in use, 0 if it is available. 19 | if request.method in ['HEAD']: 20 | return Response('', 200) 21 | 22 | elif request.method in ['GET']: 23 | if not [p for p in os.listdir(app.data_dir) if p.split('.', 1)[0] == uid]: 24 | code = '0' if app.registration else '1' 25 | else: 26 | code = '1' 27 | return Response(code, 200) 28 | 29 | # Requests that an account be created for uid 30 | elif request.method == 'PUT': 31 | if app.registration and not [p for p in os.listdir(app.data_dir) if p.startswith(uid)]: 32 | 33 | try: 34 | passwd = request.get_json()['password'] 35 | except (KeyError, TypeError): 36 | raise BadRequest(WEAVE_MISSING_PASSWORD) 37 | 38 | try: 39 | con = sqlite3.connect(app.dbpath(uid, passwd)) 40 | con.commit() 41 | con.close() 42 | except IOError: 43 | raise BadRequest(WEAVE_INVALID_WRITE) 44 | return Response(uid, 200) 45 | 46 | raise BadRequest(WEAVE_INVALID_WRITE) 47 | 48 | elif request.method == 'POST': 49 | return Response('Not Implemented', 501) 50 | 51 | elif request.method == 'DELETE': 52 | if request.authorization.username != uid: 53 | return Response('Not Authorized', 401) 54 | 55 | try: 56 | os.remove(app.dbpath(uid, request.authorization.password)) 57 | except OSError: 58 | pass 59 | return Response('0', 200) 60 | 61 | 62 | @login(['POST']) 63 | def change_password(app, environ, request, version, uid): 64 | """POST https://server/pathname/version/username/password""" 65 | 66 | if not [p for p in os.listdir(app.data_dir) if p.split('.', 1)[0] == uid]: 67 | return Response(WEAVE_INVALID_USER, 404) 68 | 69 | if len(request.get_data(as_text=True)) == 0: 70 | return Response(WEAVE_MISSING_PASSWORD, 400) 71 | elif len(request.get_data(as_text=True)) < 4: 72 | return Response(WEAVE_WEAK_PASSWORD, 400) 73 | 74 | old_dbpath = app.dbpath(uid, request.authorization.password) 75 | new_dbpath = app.dbpath(uid, request.get_data(as_text=True)) 76 | try: 77 | os.rename(old_dbpath, new_dbpath) 78 | except OSError: 79 | return Response(WEAVE_INVALID_WRITE, 503) 80 | 81 | return Response('success', 200) 82 | -------------------------------------------------------------------------------- /weave/minimal/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | import pkg_resources 5 | werkzeug = pkg_resources.get_distribution("werkzeug") 6 | 7 | import re 8 | import json 9 | import base64 10 | import struct 11 | 12 | from os.path import isfile 13 | from hashlib import sha1 14 | 15 | from werkzeug.wrappers import Request as _Request, Response 16 | from werkzeug.exceptions import BadRequest as _BadRequest 17 | 18 | from weave.minimal.compat import iterkeys 19 | from weave.minimal.constants import WEAVE_MALFORMED_JSON, WEAVE_INVALID_WBO 20 | 21 | 22 | class BadRequest(_BadRequest): 23 | """Remove fancy HTML from exceptions.""" 24 | 25 | def get_headers(self, environ): 26 | return [("Content-Type", "text/plain")] 27 | 28 | def get_body(self, environ): 29 | return self.description 30 | 31 | 32 | class Request(_Request): 33 | 34 | max_content_length = 512 * 1024 35 | 36 | if werkzeug.version.startswith("0.8"): 37 | def get_data(self, **kw): 38 | return self.data.decode('utf-8') 39 | 40 | def get_json(self): 41 | try: 42 | data = json.loads(self.get_data(as_text=True)) 43 | except ValueError: 44 | raise BadRequest(WEAVE_MALFORMED_JSON) 45 | else: 46 | if not isinstance(data, (dict, list)): 47 | raise BadRequest(WEAVE_INVALID_WBO) 48 | return data 49 | 50 | 51 | def encode(uid): 52 | if re.search('[^A-Z0-9._-]', uid, re.I): 53 | return base64.b32encode(sha1(uid).digest()).lower() 54 | return uid 55 | 56 | 57 | class login: 58 | """login decorator using HTTP Basic Authentication. Pattern based on 59 | http://flask.pocoo.org/docs/patterns/viewdecorators/""" 60 | 61 | def __init__(self, methods=['GET', 'POST', 'DELETE', 'PUT']): 62 | self.methods = methods 63 | 64 | def __call__(self, f): 65 | 66 | def dec(app, env, req, *args, **kwargs): 67 | """This decorater function will send an authenticate header, if none 68 | is present and denies access, if HTTP Basic Auth fails.""" 69 | if req.method not in self.methods: 70 | return f(app, env, req, *args, **kwargs) 71 | if not req.authorization: 72 | response = Response('Unauthorized', 401) 73 | response.www_authenticate.set_basic('Weave') 74 | return response 75 | else: 76 | user = req.authorization.username 77 | passwd = req.authorization.password 78 | if not isfile(app.dbpath(user, passwd)): 79 | return Response('Unauthorized', 401) # kinda stupid 80 | return f(app, env, req, *args, **kwargs) 81 | return dec 82 | 83 | 84 | def wbo2dict(query): 85 | """converts sqlite table to WBO (dict [json-parsable])""" 86 | 87 | res = {'id': query[0], 'modified': round(query[1], 2), 88 | 'sortindex': query[2], 'payload': query[3], 89 | 'parentid': query[4], 'predecessorid': query[5], 'ttl': query[6]} 90 | 91 | for key in list(iterkeys(res)): 92 | if res[key] is None: 93 | res.pop(key) 94 | 95 | return res 96 | 97 | 98 | def convert(value, mime): 99 | """post processor producing lists in application/newlines format.""" 100 | 101 | if mime and mime.endswith(('/newlines', '/whoisi')): 102 | try: 103 | value = value["items"] 104 | except (KeyError, TypeError): 105 | pass 106 | 107 | if mime.endswith('/whoisi'): 108 | res = [] 109 | for record in value: 110 | js = json.dumps(record) 111 | res.append(struct.pack('!I', len(js)) + js.encode('utf-8')) 112 | rv = b''.join(res) 113 | else: 114 | rv = '\n'.join(json.dumps(item).replace('\n', '\000a') for item in value) 115 | else: 116 | 117 | rv, mime = json.dumps(value), 'application/json' 118 | 119 | return rv, mime, len(value) 120 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | weave-minimal: a Firefox Sync Server that just works™ 2 | ===================================================== 3 | 4 | **DOES NOT WORK WITH FIREFOX 29 OR LATER** 5 | 6 | This is a lightweight implementation of Mozillas' [User API v1.0][1] and 7 | [Storage API v1.1][2] without LDAP, MySQL, Redis etc. overhead. It is multi 8 | users capable and depends only on [werkzeug][3]. 9 | 10 | I mean, *really* lightweight and *really* simple to install. No hg-attack clone 11 | fetch fail apt-get install. It just works. 12 | 13 | Note, that the name originates from the deprecated [Weave Minimal Server][4], 14 | but shares nothing beside the name; see [FSyncMS][5] for a still working, PHP 15 | based sync server. 16 | 17 | [1]: http://docs.services.mozilla.com/reg/apis.html 18 | [2]: http://docs.services.mozilla.com/storage/apis-1.1.html 19 | [3]: http://werkzeug.pocoo.org/ 20 | [4]: https://tobyelliott.wordpress.com/2011/03/25/updating-and-deprecating-the-weave-minimal-server/ 21 | [5]: https://github.com/balu-/FSyncMS/ 22 | 23 | Setup and Configuration 24 | ----------------------- 25 | 26 | You need Python 2.6, 2.7 or 3.3. See `weave-minimal --help` for a list of 27 | parameters and a short description. 28 | 29 | $ pip install weave-minimal 30 | $ weave-minimal --enable-registration 31 | * Running on http://127.0.0.1:8080/ 32 | 33 | To run weave-minimal as a daemon, consider this SysVinit script: 34 | 35 | ```bash 36 | $ cat /etc/init.d/weave-minimal 37 | #!/bin/sh 38 | 39 | NAME=weave-minimal 40 | USER=www 41 | CMD=/usr/local/bin/weave-minimal 42 | 43 | PORT=8080 44 | DBPATH=/var/lib/weave-minimal/ 45 | 46 | if [ ! -d $DBPATH ]; then 47 | mkdir /var/lib/weave-minimal 48 | chown $USER /var/lib/weave-minimal 49 | fi 50 | 51 | case $1 in 52 | start) 53 | echo -n "Starting $NAME." 54 | start-stop-daemon --start --background --pidfile /var/run/$NAME.pid \ 55 | --chuid $USER --make-pidfile --exec $CMD -- --data-dir=$DBPATH \ 56 | --port=$PORT --enable-registration 57 | ;; 58 | stop) start-stop-daemon --stop --pidfile /var/run/$NAME.pid 59 | ;; 60 | esac 61 | $ chmod +x /etc/init.d/weave-minimal 62 | $ sudo update-rc.d weave-minimal defaults 99 63 | $ sudo invoke-rc.d weave-minimal start 64 | ``` 65 | 66 | ### See also 67 | 68 | * [EN: Firefox Sync server right on router][6] 69 | * [DE: Uberspace und dein Firefox Sync Server][7], [FastCGI wrapper][8] 70 | 71 | [6]: http://forums.smallnetbuilder.com/showthread.php?t=10797 72 | [7]: http://christoph-polcin.com/2012/12/31/firefox-minimal-weave-auf-uberspace/ 73 | [8]: https://github.com/oa/weave-minimal-uberspace 74 | 75 | Setting up Firefox 76 | ------------------ 77 | 78 | 0. **Migrate from the official servers**: write down your email address and sync 79 | key (you can reset your password anyway) and unlink your client. If you want 80 | to keep your previous sync key, enter the key in the advanced settings. 81 | 82 | 1. **Create a new account** in the sync preferences. Choose a valid email 83 | address and password and enter the custom url into the server location 84 | (leave the trailing slash!). If you get an error, check the SSL certificate 85 | first. 86 | 87 | 2. If no errors come up, click continue and wait a minute. If you sync tabs, 88 | quit, re-open and manually sync otherwise you'll get an empty tab list. 89 | 90 | 3. **Connect other clients** is as easy as with the mozilla servers (the client 91 | actually uses mozilla's servers for this): click *I already have an account* 92 | and write the three codes into an already linked browser using *Pair Device*. 93 | Optionally you can use the manual prodecure but the you have to enter your 94 | sync key by hand. 95 | 96 | 4. If you have connected your clients, you can close the registration by running 97 | `weave-minimal` without the `--enable-registration` flag. 98 | 99 | **Q:** Is this implementation standard compliant? 100 | **A:** Yes, it passes the official functional test suite (with [minor 101 | modifications][9]). 102 | 103 | **Q:** Is it compatible with the latest version of Firefox? 104 | **A:** Not guaranteed. There is a new API draft, but not used in 105 | Firefox/Firefox ESR before 2014. 106 | 107 | **Q:** Can I use a custom certificate for HTTPS? 108 | **A:** Yes, but import the CA or visit the url before you enable syncing. 109 | Firefox will show you a misleading error "invalid url" if you did not 110 | accept this cert before! 111 | If you are using Firefox on Android, you have to accept the certificate 112 | with the default Android Browser (called "Browser"). 113 | Also [see here](#ssl-and-firefox-for-android) for 114 | information on a bug in Firefox for Android that might 115 | cause you troubles. 116 | 117 | **Q:** It does not sync! 118 | **A:** Make sure, that `$ curl http://example.tld/prefix/user/1.0/example/node/weave` 119 | returns the correct sync url. Next, try to restart your browser. If that 120 | doesn't help, please file a bug report. 121 | 122 | [9]: https://github.com/posativ/weave-minimal/issues/4#issuecomment-8268947 123 | 124 | ### Using a Custom Username 125 | 126 | Mozilla assumes, you're using their services, therefore you can not enter a 127 | non-valid email-address and Firefox will prevent you from doing this. But 128 | there's an alternate way: 129 | 130 | Instead of entering all your details into the first screen of "Firefox Sync 131 | Setup" you click on "I already have a Firefox Sync Account". Before you can go 132 | to the next step, you have to set up a user account in weave. 133 | 134 | $ weave-minimal --register bob:secret123 135 | [info] database for `bob` created at `.data/bob.c203011d1453ba7c` 136 | 137 | Now you can continue your Firefox Sync Setup and click "I don't have the device 138 | with me" and enter your username, password, "use custom server" -> url and 139 | secret passphrase. That's all. 140 | 141 | 142 | Webserver Configuration 143 | ----------------------- 144 | 145 | ### using lighttpd and mod_proxy 146 | 147 | To run weave-minimal using [lighttpd][10] and mod_proxy you need to pass the 148 | public url, e.g. `weave-minimal --base-url=http://example.org/sync ...` 149 | 150 | $HTTP["url"] =~ "^/weave/" { 151 | proxy.server = ("" => 152 | (("host" => "127.0.0.1", "port" => 8080))) 153 | setenv.add-request-header = ("X-Forwarded-Proto" => "https") # optionally for HTTPS 154 | } 155 | 156 | [10]: http://www.lighttpd.net/ 157 | 158 | ### nginx 159 | 160 | location ^~ /weave/ { 161 | proxy_set_header Host $host; 162 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 163 | proxy_set_header X-Forwarded-Proto $scheme; 164 | proxy_set_header X-Script-Name /weave; 165 | proxy_pass http://127.0.0.1:8080; 166 | } 167 | 168 | ### using apache and mod_proxy, with SSL 169 | 170 | 171 | ProxyPass http://127.0.0.1:8080 172 | RequestHeader set X-Forwarded-Proto "https" 173 | 174 | 175 | You can skip `RequestHeader`, if apache proxies the service on regular `http`. 176 | 177 | ### SSL and Firefox for Android 178 | 179 | If Firefox for Android fails to sync properly, yet doesn't give any error 180 | messages either, check out `adb logcat`. You might get an error similar to 181 | this: 182 | 183 | javax.net.ssl.SSLPeerUnverifiedException: No peer certificate 184 | 185 | That's because [Firefox for Android has a nasty 186 | bug](https://bugzilla.mozilla.org/show_bug.cgi?id=756763) that doesn't allow 187 | you to use self-signed certificates or certain ciphers. 188 | 189 | For some reason, this error occurs not because your certificate is untrusted, 190 | but because of ciphers. Adding RC4+RSA to the server's cipher list "fixes" that 191 | issue. 192 | 193 | For nginx the code to add would be: 194 | 195 | server { 196 | # ... 197 | # this makes ssl connections less secure! 198 | ssl_ciphers HIGH:!aNULL:!MD5:RC4+RSA; 199 | # ... 200 | } 201 | 202 | Deployment 203 | ---------- 204 | 205 | For higher concurrency (if possible at all with SQLite), gevent will be used if 206 | installed (`pip install gevent`). Furthermore, `weave-minimal` exports an 207 | *application* object for uWSGI and Gunicorn, e.g.: 208 | 209 | ```bash 210 | $ env ENABLE_REGISTRATION=1 DATA_DIR=/var/lib/... gunicorn weave -b localhost:1234 211 | ``` 212 | 213 | Do *not* use multiple processes to run `weave-minimal`. The code does not 214 | acquire inter-process locks on the database and I have no plans to add an IPC 215 | concurrency pattern to the rather simple code base (programmer's lame excuse, 216 | I know). 217 | -------------------------------------------------------------------------------- /weave/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | # 4 | # Copyright 2013 Martin Zimmermann . All rights reserved. 5 | # 6 | # Redistribution and use in source and binary forms, with or without 7 | # modification, are permitted provided that the following conditions are met: 8 | # 9 | # 1. Redistributions of source code must retain the above copyright notice, 10 | # this list of conditions and the following disclaimer. 11 | # 12 | # 2. Redistributions in binary form must reproduce the above copyright 13 | # notice, this list of conditions and the following disclaimer in the 14 | # documentation and/or other materials provided with the distribution. 15 | # 16 | # The views and conclusions contained in the software and documentation are 17 | # those of the authors and should not be interpreted as representing official 18 | # policies, either expressed or implied, of Martin Zimmermann . 19 | # 20 | # lightweight firefox weave/sync server 21 | 22 | from __future__ import print_function 23 | 24 | import pkg_resources 25 | dist = pkg_resources.get_distribution("weave-minimal") 26 | 27 | try: 28 | import gevent.monkey; gevent.monkey.patch_all() 29 | except ImportError: 30 | pass 31 | 32 | import sys 33 | 34 | if sys.version_info < (2, 7): 35 | reload(sys) 36 | sys.setdefaultencoding("utf-8") # yolo 37 | 38 | import os 39 | import errno 40 | import hashlib 41 | import sqlite3 42 | import logging 43 | 44 | from os.path import join, dirname 45 | from argparse import ArgumentParser, HelpFormatter, SUPPRESS 46 | 47 | try: 48 | from urllib.parse import urlsplit 49 | except ImportError: 50 | from urlparse import urlsplit 51 | 52 | from werkzeug.routing import Map, Rule, BaseConverter 53 | from werkzeug.serving import run_simple 54 | from werkzeug.wrappers import Response 55 | from werkzeug.exceptions import HTTPException, NotFound, NotImplemented 56 | 57 | from werkzeug.wsgi import SharedDataMiddleware 58 | 59 | from weave.minimal import user, storage, misc 60 | from weave.minimal.utils import encode, Request 61 | 62 | logging.basicConfig( 63 | level=logging.INFO, 64 | format="%(asctime)s %(levelname)s: %(message)s") 65 | 66 | logger = logging.getLogger("weave-minimal") 67 | 68 | 69 | class RegexConverter(BaseConverter): 70 | def __init__(self, url_map, *items): 71 | super(RegexConverter, self).__init__(url_map) 72 | self.regex = items[0] 73 | 74 | 75 | url_map = Map([ 76 | # reg-server 77 | Rule('/user//', endpoint=user.index, 78 | methods=['GET', 'HEAD', 'PUT', 'DELETE']), 79 | Rule('/user///password', 80 | endpoint=user.change_password, methods=['POST']), 81 | Rule('/user///node/weave', 82 | endpoint=lambda app, env, req, version, uid: Response(req.url_root, 200)), 83 | Rule('/user///password_reset', 84 | endpoint=lambda *args, **kw: NotImplemented()), 85 | Rule('/user///email', 86 | endpoint=lambda *args, **kw: NotImplemented()), 87 | 88 | # some useless UI stuff, not working 89 | Rule('/weave-password-reset', methods=['GET', 'HEAD', 'POST'], 90 | endpoint=lambda app, env, req: NotImplemented()), 91 | Rule('/misc//captcha_html', 92 | endpoint=misc.captcha_html), 93 | Rule('/media/', endpoint=lambda app, env, req: NotImplemented()), 94 | 95 | # info 96 | Rule('/', endpoint=misc.index), 97 | Rule('///info/collections', 98 | endpoint=storage.get_collections_info), 99 | Rule('///info/collection_counts', 100 | endpoint=storage.get_collection_counts), 101 | Rule('///info/collection_usage', 102 | endpoint=storage.get_collection_usage), 103 | Rule('///info/quota', 104 | endpoint=storage.get_quota), 105 | 106 | # storage 107 | Rule('///storage', 108 | endpoint=storage.storage, methods=['DELETE']), 109 | Rule('///storage/', 110 | endpoint=storage.collection, methods=['GET', 'HEAD', 'PUT', 'POST', 'DELETE']), 111 | Rule('///storage//', 112 | endpoint=storage.item, methods=['GET', 'HEAD', 'PUT', 'DELETE']), 113 | ], converters={'re': RegexConverter}, strict_slashes=False) 114 | 115 | 116 | class ReverseProxied(object): 117 | """ 118 | Handle X-Script-Name and X-Forwarded-Proto. E.g.: 119 | 120 | location /weave { 121 | proxy_pass http://localhost:8080; 122 | proxy_set_header X-Script-Name /weave; 123 | proxy_set_header X-Forwarded-Proto $scheme; 124 | } 125 | 126 | :param app: the WSGI application 127 | """ 128 | 129 | def __init__(self, app, base_url): 130 | self.app = app 131 | self.base_url = base_url 132 | 133 | def __call__(self, environ, start_response): 134 | 135 | prefix = None 136 | 137 | if self.base_url is not None: 138 | scheme, host, prefix, x, y = urlsplit(self.base_url) 139 | 140 | environ['wsgi.url_scheme'] = scheme 141 | environ['HTTP_HOST'] = host 142 | 143 | if 'HTTP_X_FORWARDED_PROTO' in environ: 144 | environ['wsgi.url_scheme'] = environ['HTTP_X_FORWARDED_PROTO'] 145 | 146 | script_name = environ.get('HTTP_X_SCRIPT_NAME', prefix) 147 | if script_name: 148 | environ['SCRIPT_NAME'] = script_name 149 | path_info = environ['PATH_INFO'] 150 | if path_info.startswith(script_name): 151 | environ['PATH_INFO'] = path_info[len(script_name):] 152 | 153 | return self.app(environ, start_response) 154 | 155 | 156 | class Weave(object): 157 | 158 | salt = r'\x14Q\xd4JbDk\x1bN\x84J\xd0\x05\x8a\x1b\x8b\xa6&V\x1b\xc5\x91\x97\xc4' 159 | 160 | def __init__(self, data_dir, registration): 161 | 162 | try: 163 | os.makedirs(data_dir) 164 | except OSError as ex: 165 | if ex.errno != errno.EEXIST: 166 | raise 167 | 168 | self.data_dir = data_dir 169 | self.registration = registration 170 | 171 | def crypt(self, password): 172 | return hashlib.sha1((self.salt+password).encode('utf-8')).hexdigest()[:16] 173 | 174 | def dbpath(self, user, password): 175 | return join(self.data_dir, (user + '.' + self.crypt(password))) 176 | 177 | def initialize(self, uid, password): 178 | 179 | dbpath = self.dbpath(uid, password) 180 | 181 | try: 182 | os.unlink(dbpath) 183 | except OSError: 184 | pass 185 | 186 | with sqlite3.connect(dbpath) as con: 187 | con.commit() 188 | 189 | logger.info("database for `%s` created at `%s`", uid, dbpath) 190 | 191 | def dispatch(self, request, start_response): 192 | adapter = url_map.bind_to_environ(request.environ) 193 | try: 194 | handler, values = adapter.match() 195 | return handler(self, request.environ, request, **values) 196 | except NotFound: 197 | return Response('Not Found', 404) 198 | except HTTPException as e: 199 | return e 200 | 201 | def wsgi_app(self, environ, start_response): 202 | request = Request(environ) 203 | response = self.dispatch(request, start_response) 204 | if hasattr(response, 'headers'): 205 | response.headers['X-Weave-Backoff'] = 0 # we have no load!1 206 | return response(environ, start_response) 207 | 208 | def __call__(self, environ, start_response): 209 | return self.wsgi_app(environ, start_response) 210 | 211 | 212 | def make_app(data_dir='.data/', base_url=None, register=False): 213 | application = Weave(data_dir, register) 214 | application.wsgi_app = SharedDataMiddleware(application.wsgi_app, { 215 | "/static": join(dirname(__file__), "static")}) 216 | application.wsgi_app = ReverseProxied(application.wsgi_app, base_url) 217 | return application 218 | 219 | 220 | def main(): 221 | 222 | fmt = lambda prog: HelpFormatter(prog, max_help_position=28) 223 | desc = u"A lightweight Firefox Sync server, that just works™. If it " \ 224 | u"doesn't just work for you, please file a bug: " \ 225 | u"https://github.com/posativ/weave-minimal/issues" 226 | 227 | parser = ArgumentParser(description=desc, formatter_class=fmt) 228 | option = parser.add_argument 229 | 230 | option("--host", dest="host", default="127.0.0.1", type=str, 231 | metavar="127.0.0.1", help="host interface") 232 | option("--port", dest="port", default=8080, type=int, metavar="8080", 233 | help="port to listen on") 234 | option("--log-file", dest="logfile", default=None, type=str, 235 | metavar="FILE", help="log to a file") 236 | 237 | option("--data-dir", dest="data_dir", default=".data/", metavar="/var/...", 238 | help="directory to store sync data, defaults to .data/") 239 | option("--enable-registration", dest="registration", action="store_true", 240 | help="enable public registration"), 241 | option("--base-url", dest="base_url", default=None, metavar="URL", 242 | help="public URL, e.g. https://example.org/weave/") 243 | option("--register", dest="creds", default=None, metavar="user:pass", 244 | help="register a new user and exit") 245 | 246 | option("--use-reloader", action="store_true", dest="reloader", 247 | help=SUPPRESS, default=False) 248 | 249 | option("--version", action="store_true", dest="version", 250 | help=SUPPRESS, default=False) 251 | 252 | options = parser.parse_args() 253 | 254 | if options.version: 255 | print('weave-minimal', dist.version, end=' ') 256 | print('(Storage API 1.1, User API 1.0)') 257 | sys.exit(0) 258 | 259 | if options.logfile: 260 | handler = logging.FileHandler(options.logfile) 261 | 262 | logger.addHandler(handler) 263 | logging.getLogger('werkzeug').addHandler(handler) 264 | 265 | logger.propagate = False 266 | logging.getLogger('werkzeug').propagate = False 267 | 268 | app = make_app(options.data_dir, options.base_url, options.registration) 269 | 270 | if options.creds: 271 | 272 | try: 273 | username, passwd = options.creds.split(':', 1) 274 | except ValueError: 275 | logger.error("provide credentials as `user:pass`!") 276 | sys.exit(os.EX_DATAERR) 277 | 278 | if len(passwd) < 8: 279 | logger.error("password too short, minimum length is 8") 280 | sys.exit(os.EX_DATAERR) 281 | 282 | app.initialize(encode(username), passwd) 283 | sys.exit(os.EX_OK) 284 | 285 | try: 286 | from gevent.pywsgi import WSGIServer 287 | WSGIServer((options.host, options.port), app).serve_forever() 288 | except ImportError: 289 | run_simple(options.host, options.port, app, use_reloader=options.reloader, threaded=True) 290 | 291 | 292 | if sys.argv[0].endswith(("gunicorn", "uwsgi")): 293 | application = make_app( 294 | data_dir=os.environ.get("DATA_DIR", ".data/"), 295 | base_url=os.environ.get("BASE_URL", None), 296 | register=bool(os.environ.get("ENABLE_REGISTRATION", "0"))) 297 | -------------------------------------------------------------------------------- /weave/minimal/storage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | 4 | import sys 5 | import math 6 | import time 7 | import json 8 | import sqlite3 9 | 10 | from werkzeug.wrappers import Response 11 | from werkzeug.exceptions import PreconditionFailed 12 | 13 | if sys.version_info < (2, 7): 14 | import __builtin__ 15 | 16 | class Float(float): 17 | 18 | def __new__(self, x, i): 19 | self.i = i 20 | return float.__new__(self, x) 21 | 22 | def __repr__(self): 23 | return (('%%.%sf' % self.i ) % self) 24 | 25 | defaultround = round 26 | setattr(__builtin__, 'round', lambda x, i: Float(defaultround(x, 2), i)) 27 | 28 | from weave.minimal.utils import login, wbo2dict, convert, BadRequest 29 | from weave.minimal.compat import iteritems 30 | from weave.minimal.constants import WEAVE_INVALID_WBO 31 | 32 | FIELDS = ['id', 'modified', 'sortindex', 'payload', 'parentid', 'predecessorid', 'ttl'] 33 | 34 | 35 | def iter_collections(dbpath): 36 | """iters all available collection_ids""" 37 | with sqlite3.connect(dbpath) as db: 38 | res = db.execute("SELECT name FROM sqlite_master WHERE type='table';").fetchall() 39 | return [x[0] for x in res] 40 | 41 | 42 | def expire(dbpath, cid): 43 | try: 44 | with sqlite3.connect(dbpath) as db: 45 | db.execute("DELETE FROM %s WHERE (%s - modified) > ttl" % (cid, time.time())) 46 | except sqlite3.OperationalError: 47 | pass 48 | 49 | 50 | def has_modified(since, dbpath, cid): 51 | """On any write transaction (PUT, POST, DELETE), if the collection to be acted 52 | on has been modified since the provided timestamp, the request will fail with 53 | an HTTP 412 Precondition Failed status.""" 54 | 55 | with sqlite3.connect(dbpath) as db: 56 | 57 | try: 58 | sql = 'SELECT MAX(modified) FROM %s' % cid 59 | rv = db.execute(sql).fetchone() 60 | except sqlite3.OperationalError: 61 | return False 62 | 63 | return rv and since < rv[0] 64 | 65 | 66 | def set_item(dbpath, uid, cid, data): 67 | 68 | obj = {'id': data['id']} 69 | obj['modified'] = round(time.time(), 2) 70 | obj['payload'] = data.get('payload', None) 71 | obj['payload_size'] = len(obj['payload']) if obj['payload'] else 0 72 | obj['sortindex'] = data.get('sortindex', None) 73 | obj['parentid'] = data.get('parentid', None) 74 | obj['predecessorid'] = data.get('predecessorid', None) 75 | obj['ttl'] = data.get('ttl', None) 76 | 77 | if obj['sortindex']: 78 | try: 79 | obj['sortindex'] = int(math.floor(float(obj['sortindex']))) 80 | except ValueError: 81 | return obj 82 | 83 | with sqlite3.connect(dbpath) as db: 84 | sql = ('main.%s (id VARCHAR(64) PRIMARY KEY, modified FLOAT,' 85 | 'sortindex INTEGER, payload VARCHAR(256),' 86 | 'payload_size INTEGER, parentid VARCHAR(64),' 87 | 'predecessorid VARCHAR(64), ttl INTEGER)') % cid 88 | db.execute("CREATE table IF NOT EXISTS %s;" % sql) 89 | 90 | into = []; values = [] 91 | for k,v in iteritems(obj): 92 | into.append(k); values.append(v) 93 | 94 | try: 95 | db.execute("INSERT INTO %s (%s) VALUES (%s);" % \ 96 | (cid, ', '.join(into), ','.join(['?' for x in values])), values) 97 | except sqlite3.IntegrityError: 98 | for k,v in iteritems(obj): 99 | if v is None: continue 100 | db.execute('UPDATE %s SET %s=? WHERE id=?;' % (cid, k), [v, obj['id']]) 101 | except sqlite3.InterfaceError: 102 | raise ValueError 103 | 104 | return obj 105 | 106 | 107 | @login(['GET', 'HEAD']) 108 | def get_collections_info(app, environ, request, version, uid): 109 | """Returns a hash of collections associated with the account, 110 | Along with the last modified timestamp for each collection. 111 | """ 112 | if request.method == 'HEAD' or request.authorization.username != uid: 113 | return Response('Not Authorized', 401) 114 | 115 | dbpath = app.dbpath(uid, request.authorization.password) 116 | ids = iter_collections(dbpath); collections = {} 117 | 118 | with sqlite3.connect(dbpath) as db: 119 | for id in ids: 120 | x = db.execute('SELECT id, MAX(modified) FROM %s;' % id).fetchall() 121 | for k,v in x: 122 | if not k: 123 | continue # XXX: why None, None yields here? 124 | collections[id] = round(v, 2) 125 | 126 | return Response(json.dumps(collections), 200, content_type='application/json', 127 | headers={'X-Weave-Records': str(len(collections))}) 128 | 129 | 130 | @login(['GET', 'HEAD']) 131 | def get_collection_counts(app, environ, request, version, uid): 132 | """Returns a hash of collections associated with the account, 133 | Along with the total number of items for each collection. 134 | """ 135 | if request.method == 'HEAD' or request.authorization.username != uid: 136 | return Response('Not Authorized', 401) 137 | 138 | dbpath = app.dbpath(uid, request.authorization.password) 139 | ids = iter_collections(dbpath); collections = {} 140 | 141 | with sqlite3.connect(dbpath) as db: 142 | for id in ids: 143 | cur = db.execute('SELECT id FROM %s;' % id) 144 | collections[id] = len(cur.fetchall()) 145 | 146 | return Response(json.dumps(collections), 200, content_type='application/json', 147 | headers={'X-Weave-Records': str(len(collections))}) 148 | 149 | 150 | @login(['GET', 'HEAD']) 151 | def get_collection_usage(app, environ, request, version, uid): 152 | """Returns a hash of collections associated with the account, along with 153 | the data volume used for each (in K). 154 | """ 155 | if request.method == 'HEAD' or request.authorization.username != uid: 156 | return Response('Not Authorized', 401) 157 | 158 | dbpath = app.dbpath(uid, request.authorization.password) 159 | with sqlite3.connect(dbpath) as db: 160 | res = {} 161 | for table in iter_collections(dbpath): 162 | v = db.execute('SELECT SUM(payload_size) FROM %s' % table).fetchone()[0] or 0 163 | res[table] = v/1024.0 164 | 165 | js = json.dumps(res) 166 | return Response(js, 200, content_type='application/json', 167 | headers={'X-Weave-Records': str(len(js))}) 168 | 169 | 170 | @login(['GET', 'HEAD']) 171 | def get_quota(app, environ, request, version, uid): 172 | if request.method == 'HEAD' or request.authorization.username != uid: 173 | return Response('Not Authorized', 401) 174 | 175 | dbpath = app.dbpath(uid, request.authorization.password) 176 | with sqlite3.connect(dbpath) as db: 177 | sum = 0 178 | for table in iter_collections(dbpath): 179 | sum += db.execute('SELECT SUM(payload_size) FROM %s' % table).fetchone()[0] or 0 180 | # sum = os.path.getsize(dbpath) # -- real usage 181 | 182 | js = json.dumps([sum/1024.0, None]) 183 | return Response(js, 200, content_type='application/json', 184 | headers={'X-Weave-Records': str(len(js))}) 185 | 186 | 187 | def storage(app, environ, request, version, uid): 188 | 189 | if request.method == 'DELETE': 190 | if request.headers.get('X-Confirm-Delete', '0') == '1': 191 | app.initialize(uid, request.authorization.password) 192 | 193 | return Response(json.dumps(time.time()), 200) 194 | 195 | return Response('Precondition Failed', 412) 196 | 197 | 198 | @login(['GET', 'HEAD', 'POST', 'PUT', 'DELETE']) 199 | def collection(app, environ, request, version, uid, cid): 200 | """///storage/""" 201 | 202 | if request.method == 'HEAD' or request.authorization.username != uid: 203 | return Response('Not Authorized', 401) 204 | 205 | dbpath = app.dbpath(uid, request.authorization.password) 206 | expire(dbpath, cid) 207 | 208 | ids = request.args.get('ids', None) 209 | offset = request.args.get('offset', None) 210 | older = request.args.get('older', None) 211 | newer = request.args.get('newer', None) 212 | full = request.args.get('full', False) 213 | index_above = request.args.get('index_above', None) 214 | index_below = request.args.get('index_below', None) 215 | limit = request.args.get('limit', None) 216 | offset = request.args.get('offset', None) 217 | sort = request.args.get('sort', None) 218 | parentid = request.args.get('parentid', None) 219 | predecessorid = request.args.get('predecessorid', None) 220 | 221 | try: 222 | older and float(older) 223 | newer and float(newer) 224 | limit and int(limit) 225 | offset and int(offset) 226 | index_above and int(index_above) 227 | index_below and int(index_below) 228 | except ValueError: 229 | raise BadRequest 230 | 231 | if limit is not None: 232 | limit = int(limit) 233 | 234 | if offset is not None: 235 | # we need both 236 | if limit is None: 237 | offset = None 238 | else: 239 | offset = int(offset) 240 | 241 | if not full: 242 | fields = ['id'] 243 | else: 244 | fields = FIELDS 245 | 246 | # filters used in WHERE clause 247 | filters = {} 248 | if ids is not None: 249 | filters['id'] = 'IN', '(%s)' % ",".join("'" + x.strip() + "'" for x in ids.split(",")) 250 | if older is not None: 251 | filters['modified'] = '<', float(older) 252 | if newer is not None: 253 | filters['modified'] = '>', float(newer) 254 | if index_above is not None: 255 | filters['sortindex'] = '>', int(index_above) 256 | if index_below is not None: 257 | filters['sortindex'] = '<', int(index_below) 258 | if parentid is not None: 259 | filters['parentid'] = '=', "'%s'" % parentid 260 | if predecessorid is not None: 261 | filters['predecessorid'] = '=', "'%s'" % predecessorid 262 | 263 | filter_query, sort_query, limit_query = '', '', '' 264 | 265 | # ORDER BY x ASC|DESC 266 | if sort is not None: 267 | if sort == 'index': 268 | sort_query = ' ORDER BY sortindex DESC' 269 | elif sort == 'oldest': 270 | sort_query = ' ORDER BY modified ASC' 271 | elif sort == 'newest': 272 | sort_query = ' ORDER BY modified DESC' 273 | 274 | # WHERE x