commit 1059a4d62dbda70ab0857a5b1a1b720b3514a68b Author: Drew DeVault Date: Fri Sep 4 22:51:20 2015 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..924eaa6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.pyc +bin/ +config.ini +alembic.ini +include/ +local/ +lib/ +static/ +*.swp +*.rdb +storage/ +pip-selfcheck.json +.sass-cache/ +overrides/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..932e1df --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2015 Drew DeVault + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0eaf634 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +# Builds static assets +# Depends on: +# - scss +# - coffeescript +# - inotify-tools +# Run `make` to compile static assets +# Run `make watch` to recompile whenever a change is made + +.PHONY: all static watch clean + +STYLES:=$(patsubst styles/%.scss,static/%.css,$(wildcard styles/*.scss)) +STYLES+=$(patsubst styles/%.css,static/%.css,$(wildcard styles/*.css)) +SCRIPTS:=$(patsubst scripts/%.coffee,static/%.js,$(wildcard scripts/*.coffee)) +SCRIPTS+=$(patsubst scripts/%.js,static/%.js,$(wildcard scripts/*.js)) +_STATIC:=$(patsubst _static/%,static/%,$(wildcard _static/*)) + +static/%: _static/% + @mkdir -p static/ + cp $< $@ + +static/%.css: styles/%.css + @mkdir -p static/ + cp $< $@ + +static/%.css: styles/%.scss + @mkdir -p static/ + scss -I styles/ $< $@ + +static/%.js: scripts/%.js + @mkdir -p static/ + cp $< $@ + +static/%.js: scripts/%.coffee + @mkdir -p static/ + coffee -m -o static/ -c $< + +static: $(STYLES) $(SCRIPTS) $(_STATIC) + +all: static + echo $(STYLES) + echo $(SCRIPTS) + +clean: + rm -rf static + +watch: + while inotifywait \ + -e close_write scripts/ \ + -e close_write styles/ \ + -e close_write _static/; \ + do make; done diff --git a/README.md b/README.md new file mode 100644 index 0000000..8adcbd6 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# fosspay + +Helps you get paid for your open source work. + +[![](https://img.shields.io/badge/Donations-fosspay-brightgreen.svg)](https://drewdevault.com/donate) + +## Rationale + +I write a ton of open source software, but almost none of it is on the scale +that I can expect reliable income from donations, or the sorts of projects that +a business would be likely to fund. It's very unlikely that I'd receive enough +donations from random folks to support full time open source work, but full time +is the best way to make serious progress on your projects. + +So - here's how this works: supporters give you one-time or recurring donations, +and after a while you get enough to take a week off from work to spend on open +source work. Since I have several projects, I also ask supporters to tell me +what project they're donating towards, and I distribute the load based on which +projects receive the most support. + +## Before you start + +Talk to your employer. The way that this is designed to work is that you +continue working full-time at your job, and collect donations. After a while, +you should have enough donations to take some period of unpaid leave - a week, a +month, or whatever works. + +* You keep your current job and job security +* You get paid to work on FOSS even with flaky or inconsistent donations +* Everyone wins + +There are a few things you need to talk about with your employer: + +1. Make sure you own the IP for the things you write during your open source + sprints. +1. Make sure that you have a job to come back to afterwards. +1. Research the tax implications of accepting these donations. + +### Stripe + +Payments are taken through Stripe, which is pretty headache-free for you to use. +You need to set up an approved Stripe account, which you can get from here: + +https://stripe.com/ + +### Mandrill + +You will need a mail server of some sort. If you don't want to go through the +trouble of setting one up, you can use Mandrill: + +http://mandrill.com/ + +You can probably also use your existing mail server, which is what I do, which +makes it easy for people to email you questions and such. + +### SSL + +You will need an SSL certificate for your website (you also need a domain name). +You can get a free SSL certificate from [StartSSL](http://www.startssl.com/), +but they've always felt pretty... bad to me. You can pay for one instead at +[RapidSSL](https://www.rapidssl.com/), which is what I use personally. You can +also get one for free from [Let's Encrypt](https://letsencrypt.org/) if that +ever happens. + +If you need a domain, you can use my referral link for +[Namecheap](http://www.namecheap.com/?aff=84838) and that'd be super nice of +you. Here's a link to Namecheap without the referral link: +[Namecheap](http://www.namecheap.com). + +## Installation + +Install these things (Arch Linux packages in parenthesis): + +* Python 3 (python) +* PostgreSQL (postgresql) +* scss (ruby-sass) +* Flask (python-flask) +* SQLAlchemy (python-sqlalchemy) +* Flask-Login (python-flask-login) +* psycopg2 (python-psycopg2) +* bcrypt (python-bcrypt) + +You'll have to configure PostgreSQL yourself and get a connection string that +fosspay can use. Then you can clone this repository to wherever you want to run +it from (I suggest making an unprivledged user account on the server you want to +host this on). + +### Configuration + +Copy config.ini.example to config.ini and edit it to your liking. Then, you can +run this command to try the site in development mode: + + python app.py + +[Click here](http://localhost:5000) to visit your donation site and further +instructions will be provided there. + +### Production Deployment + +To deploy this to production, copy the systemd unit from `contrib/` to your +server at `/etc/systemd/system/` (or whatever's appropriate for your distro). +Use `sytsemctl enable fosspay` and `systemctl start fosspay` to run the site on +`127.0.0.1:8000` (you can change this port by editing the unit file). You should +configure nginx to proxy through to fosspay from whatever other website you +already have. My nginx config is provided in `contrib/` for you to take a look +at - it proxies most requests to Github pages (my blog), and `/donate` to +fosspay. diff --git a/app.py b/app.py new file mode 100644 index 0000000..298d4d8 --- /dev/null +++ b/app.py @@ -0,0 +1,11 @@ +from fosspay.app import app +from fosspay.config import _cfg, _cfgi + +import os + +app.static_folder = os.path.join(os.getcwd(), "static") + +import os + +if __name__ == '__main__': + app.run(host=_cfg("debug-host"), port=_cfgi('debug-port'), debug=True) diff --git a/config.ini.example b/config.ini.example new file mode 100644 index 0000000..4ec1046 --- /dev/null +++ b/config.ini.example @@ -0,0 +1,24 @@ +[dev] +# Change this to the actual location of your site +protocol=http +domain=localhost:5000 +# Change this value to something random and secret +secret-key=hello world + +# On the debug server, this lets you choose what to bind to +debug-host=0.0.0.0 +debug-port=5000 + +# Fill out these details with your mail server +smtp-host=mail.you.com +smtp-port=587 +smtp-user=you +smtp-password=password +smtp-from=donate@you.com + +# Your information +your_name=Joe Bloe +your_email=joe@bloe.com + +# SQL connection string +connection-string=postgresql://postgres@localhost/fosspay diff --git a/fosspay/app.py b/fosspay/app.py new file mode 100644 index 0000000..1793b76 --- /dev/null +++ b/fosspay/app.py @@ -0,0 +1,74 @@ +from flask import Flask, render_template, request, g, Response, redirect, url_for +from flask.ext.login import LoginManager, current_user +from jinja2 import FileSystemLoader, ChoiceLoader + +import sys +import os +import locale + +from fosspay.config import _cfg, _cfgi +from fosspay.database import db, init_db +from fosspay.objects import User +from fosspay.common import * +from fosspay.network import * + +from fosspay.blueprints.html import html + +app = Flask(__name__) +app.secret_key = _cfg("secret-key") +app.jinja_env.cache = None +init_db() +login_manager = LoginManager() +login_manager.init_app(app) + +app.jinja_loader = ChoiceLoader([ + FileSystemLoader("overrides"), + FileSystemLoader("templates"), +]) + +@login_manager.user_loader +def load_user(email): + return User.query.filter(User.email == email).first() + +login_manager.anonymous_user = lambda: None + +app.register_blueprint(html) + +try: + locale.setlocale(locale.LC_ALL, 'en_US') +except: + pass + +if not app.debug: + @app.errorhandler(500) + def handle_500(e): + # shit + try: + db.rollback() + db.close() + except: + # shit shit + print("We're very borked, letting init system kick us back up") + sys.exit(1) + return render_template("internal_error.html"), 500 + +@app.errorhandler(404) +def handle_404(e): + return render_template("not_found.html"), 404 + +@app.context_processor +def inject(): + return { + 'root': _cfg("protocol") + "://" + _cfg("domain"), + 'domain': _cfg("domain"), + 'protocol': _cfg("protocol"), + 'len': len, + 'any': any, + 'request': request, + 'locale': locale, + 'url_for': url_for, + 'file_link': file_link, + 'user': current_user, + '_cfg': _cfg, + 'debug': app.debug + } diff --git a/fosspay/blueprints/html.py b/fosspay/blueprints/html.py new file mode 100644 index 0000000..ac15f66 --- /dev/null +++ b/fosspay/blueprints/html.py @@ -0,0 +1,39 @@ +from flask import Blueprint, render_template, abort, request, redirect, session, url_for, send_file, Response +from flask.ext.login import current_user, login_user, logout_user +from fosspay.objects import * +from fosspay.database import db +from fosspay.common import * +from fosspay.config import _cfg, load_config + +import locale + +encoding = locale.getdefaultlocale()[1] +html = Blueprint('html', __name__, template_folder='../../templates') + +@html.route("/") +def index(): + if User.query.count() == 0: + load_config() + return render_template("setup.html") + return render_template("index.html") + +@html.route("/setup", methods=["POST"]) +def setup(): + if not User.query.count() == 0: + abort(400) + email = request.form.get("email") + password = request.form.get("password") + if not email or not password: + return redirect("/") # TODO: Tell them what they did wrong (i.e. being stupid) + user = User(email, password) + user.admin = True + db.add(user) + db.commit() + login_user(user) + return redirect("/admin?first-run=1") + +@html.route("/admin") +@adminrequired +def admin(): + first=bool(request.args.get("first-run")) + return render_template("admin.html", first=first) diff --git a/fosspay/common.py b/fosspay/common.py new file mode 100644 index 0000000..ff0abfb --- /dev/null +++ b/fosspay/common.py @@ -0,0 +1,104 @@ +from flask import session, jsonify, redirect, request, Response, abort +from flask.ext.login import current_user +from werkzeug.utils import secure_filename +from functools import wraps +from fosspay.objects import User +from fosspay.database import db, Base +from fosspay.config import _cfg + +import json +import urllib +import requests +import xml.etree.ElementTree as ET +import hashlib + +def firstparagraph(text): + try: + para = text.index("\n\n") + return text[:para + 2] + except: + try: + para = text.index("\r\n\r\n") + return text[:para + 4] + except: + return text + +def with_session(f): + @wraps(f) + def go(*args, **kw): + try: + ret = f(*args, **kw) + db.commit() + return ret + except: + db.rollback() + db.close() + raise + return go + +def loginrequired(f): + @wraps(f) + def wrapper(*args, **kwargs): + if not current_user: + return redirect("/login?return_to=" + urllib.parse.quote_plus(request.url)) + else: + return f(*args, **kwargs) + return wrapper + +def adminrequired(f): + @wraps(f) + def wrapper(*args, **kwargs): + if not current_user: + return redirect("/login?return_to=" + urllib.parse.quote_plus(request.url)) + else: + if not current_user.admin: + abort(401) + return f(*args, **kwargs) + return wrapper + +def json_output(f): + @wraps(f) + def wrapper(*args, **kwargs): + def jsonify_wrap(obj): + jsonification = json.dumps(obj) + return Response(jsonification, mimetype='application/json') + + result = f(*args, **kwargs) + if isinstance(result, tuple): + return jsonify_wrap(result[0]), result[1] + if isinstance(result, dict): + return jsonify_wrap(result) + if isinstance(result, list): + return jsonify_wrap(result) + + # This is a fully fleshed out response, return it immediately + return result + + return wrapper + +def cors(f): + @wraps(f) + def wrapper(*args, **kwargs): + res = f(*args, **kwargs) + if request.headers.get('x-cors-status', False): + if isinstance(res, tuple): + json_text = res[0].data + code = res[1] + else: + json_text = res.data + code = 200 + + o = json.loads(json_text) + o['x-status'] = code + + return jsonify(o) + + return res + + return wrapper + +def file_link(path): + return _cfg("protocol") + "://" + _cfg("domain") + "/" + path + +def disown_link(path): + return _cfg("protocol") + "://" + _cfg("domain") + "/disown?filename=" + path diff --git a/fosspay/config.py b/fosspay/config.py new file mode 100644 index 0000000..730b896 --- /dev/null +++ b/fosspay/config.py @@ -0,0 +1,33 @@ +import logging + +try: + from configparser import ConfigParser +except ImportError: + # Python 2 support + from ConfigParser import ConfigParser + +logger = logging.getLogger("fosspay") +logger.setLevel(logging.DEBUG) + +sh = logging.StreamHandler() +sh.setLevel(logging.DEBUG) +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +sh.setFormatter(formatter) + +logger.addHandler(sh) + +# scss logger +logging.getLogger("scss").addHandler(sh) + +env = 'dev' +config = None + +def load_config(): + global config + config = ConfigParser() + config.readfp(open('config.ini')) + +load_config() + +_cfg = lambda k: config.get(env, k) +_cfgi = lambda k: int(_cfg(k)) diff --git a/fosspay/database.py b/fosspay/database.py new file mode 100644 index 0000000..1894b22 --- /dev/null +++ b/fosspay/database.py @@ -0,0 +1,14 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker +from sqlalchemy.ext.declarative import declarative_base + +from .config import _cfg, _cfgi +engine = create_engine(_cfg('connection-string')) +db = scoped_session(sessionmaker(autocommit=False, autoflush=False, bind=engine)) + +Base = declarative_base() +Base.query = db.query_property() + +def init_db(): + import fosspay.objects + Base.metadata.create_all(bind=engine) diff --git a/fosspay/network.py b/fosspay/network.py new file mode 100644 index 0000000..c7c2676 --- /dev/null +++ b/fosspay/network.py @@ -0,0 +1,19 @@ +def makeMask(n): + "return a mask of n bits as a long integer" + return (2 << n - 1) - 1 + + +def dottedQuadToNum(ip): + "convert decimal dotted quad string to long integer" + parts = ip.split(".") + return int(parts[0]) | (int(parts[1]) << 8) | (int(parts[2]) << 16) | (int(parts[3]) << 24) + + +def networkMask(ip, bits): + "Convert a network address to a long integer" + return dottedQuadToNum(ip) & makeMask(bits) + + +def addressInNetwork(ip, net): + "Is an address in a network" + return ip & net == net diff --git a/fosspay/objects.py b/fosspay/objects.py new file mode 100644 index 0000000..9f185da --- /dev/null +++ b/fosspay/objects.py @@ -0,0 +1,42 @@ +from sqlalchemy import Column, Integer, String, Unicode, Boolean, DateTime +from sqlalchemy import ForeignKey, Table, UnicodeText, Text, text +from sqlalchemy.orm import relationship, backref +from .database import Base + +from datetime import datetime +import bcrypt +import os +import hashlib + +class User(Base): + __tablename__ = 'user' + id = Column(Integer, primary_key = True) + email = Column(String(256), nullable = False, index = True) + admin = Column(Boolean()) + password = Column(String) + created = Column(DateTime) + passwordReset = Column(String(128)) + passwordResetExpiry = Column(DateTime) + + def set_password(self, password): + self.password = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + def __init__(self, email, password): + self.email = email + self.admin = False + self.created = datetime.now() + self.set_password(password) + + def __repr__(self): + return '' % self.username + + # Flask.Login stuff + # We don't use most of these features + def is_authenticated(self): + return True + def is_active(self): + return True + def is_anonymous(self): + return False + def get_id(self): + return self.email diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a15cd06 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +stripe +Flask +Jinja2 +Flask-Misaka +gunicorn diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..6541cf6 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,64 @@ +{% extends "layout.html" %} +{% block body %} +

Fosspay Admin

+{% if first %} +
+

+ You're set up and ready to go! This is your admin panel. + Yeah, it's not pretty. Next steps: +

+
    +
  1. + Add some projects. Donors can tell you which project they want to support + when they donate. +
  2. +
  3. + Customize the look & feel. Look at the contents of the templates + directory - you can copy and paste any of these templates into the + overrides directory and change it to suit your needs. +
  4. +
  5. + Donate to fosspay upstream? +
  6. +
  7. + Contribute code to fosspay upstream? +
  8. +
+
+{% endif %} +

Projects

+
+
+ + + + + + + + +
Project NameOne-off donationsRecurring donations
+
+
+

Add Project

+
+
+ +
+ +
+
+
+

Donation History

+ + + + + + + + + + +
EmailProjectCommentAmountRecurring
+{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..2e64610 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,14 @@ + + + + {% block title %} + Donate to {{_cfg("your-name")}} + {% endblock %} + + + +
+ {% block body %}{% endblock %} +
+ + diff --git a/templates/setup.html b/templates/setup.html new file mode 100644 index 0000000..8276295 --- /dev/null +++ b/templates/setup.html @@ -0,0 +1,73 @@ +{% extends "layout.html" %} +{% block body %} +

FossPay Setup

+

Congrats! You have FossPay up and running.

+ +

config.ini

+ +

You can make changes and refresh this page if you like.

+ +

Admin Account

+

Enter your details for the admin account:

+
+
+ +
+
+ +
+ +
+ +{% endblock %}