├── backend ├── .env ├── boltathon │ ├── __init__.py │ ├── static │ ├── views │ │ ├── __init__.py │ │ ├── oauth.py │ │ ├── api.py │ │ └── templates.py │ ├── models │ │ ├── __init__.py │ │ ├── tip.py │ │ ├── user.py │ │ └── connection.py │ ├── templates │ │ └── emails │ │ │ ├── template.txt │ │ │ ├── tip_received.txt │ │ │ ├── tip_error.txt │ │ │ ├── tip_received.html │ │ │ ├── tip_error.html │ │ │ └── template.html │ ├── util │ │ ├── errors.py │ │ ├── __init__.py │ │ ├── auth.py │ │ ├── blockstack.py │ │ ├── node.py │ │ ├── mail.py │ │ ├── bech32.py │ │ └── lnaddr.py │ ├── extensions.py │ ├── settings.py │ └── app.py ├── migrations │ ├── README │ ├── script.py.mako │ ├── alembic.ini │ ├── versions │ │ ├── 0b3fa2e55459_.py │ │ └── 87a598d296cb_.py │ └── env.py ├── app.py └── requirements.txt ├── frontend ├── .env ├── src │ ├── images │ │ ├── carl.jpg │ │ ├── will.png │ │ ├── favicon.png │ │ ├── og-image.png │ │ ├── tip-button-blue.png │ │ ├── tip-button-dark.png │ │ ├── tip-button-light.png │ │ ├── tip-button-orange.png │ │ ├── blockstack.svg │ │ └── logo.svg │ ├── fonts │ │ ├── PT_Sans-Web-Bold.ttf │ │ ├── PT_Sans-Web-Italic.ttf │ │ ├── PT_Sans-Web-Regular.ttf │ │ └── PT_Sans-Web-BoldItalic.ttf │ ├── util │ │ ├── env.ts │ │ ├── constants.ts │ │ └── formatters.ts │ ├── pages │ │ ├── Profile.less │ │ ├── ProfileSetup.tsx │ │ ├── Tip.tsx │ │ ├── Home.less │ │ ├── BlockstackAuth.tsx │ │ ├── About.less │ │ ├── Home.tsx │ │ ├── About.tsx │ │ └── Profile.tsx │ ├── style │ │ ├── index.less │ │ ├── variables.less │ │ └── fonts.less │ ├── components │ │ ├── ConnectionsForm.less │ │ ├── ProfileTips.less │ │ ├── ProfileHeader.less │ │ ├── Template.less │ │ ├── EmbedForm.less │ │ ├── Template.tsx │ │ ├── UserSearch.tsx │ │ ├── ProfileHeader.tsx │ │ ├── NodeForm.less │ │ ├── TipForm.less │ │ ├── EmbedForm.tsx │ │ ├── ProfileTips.tsx │ │ ├── ConnectionsForm.tsx │ │ ├── TipForm.tsx │ │ └── NodeForm.tsx │ ├── index.html │ ├── index.tsx │ ├── App.tsx │ └── api.ts ├── types │ ├── images.d.ts │ └── react-syntax-highlighter.d.ts ├── tsconfig.json ├── package.json └── webpack.config.js ├── Procfile ├── .gitattributes ├── .gitignore ├── .buildpacks ├── Aptfile ├── .env.example └── README.md /backend/.env: -------------------------------------------------------------------------------- 1 | ../.env -------------------------------------------------------------------------------- /frontend/.env: -------------------------------------------------------------------------------- 1 | ../.env -------------------------------------------------------------------------------- /backend/boltathon/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/boltathon/static: -------------------------------------------------------------------------------- 1 | ../../frontend/dist/ -------------------------------------------------------------------------------- /backend/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /backend/boltathon/views/__init__.py: -------------------------------------------------------------------------------- 1 | from . import api 2 | from . import oauth 3 | from . import templates -------------------------------------------------------------------------------- /frontend/src/images/carl.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/images/carl.jpg -------------------------------------------------------------------------------- /frontend/src/images/will.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/images/will.png -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: cd backend && flask db upgrade && gunicorn boltathon.app:create_app\(\) -b 0.0.0.0:$PORT -w 1 2 | -------------------------------------------------------------------------------- /backend/boltathon/models/__init__.py: -------------------------------------------------------------------------------- 1 | from . import user 2 | from . import connection 3 | from . import tip 4 | -------------------------------------------------------------------------------- /frontend/src/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/images/favicon.png -------------------------------------------------------------------------------- /frontend/src/images/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/images/og-image.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | frontend/yarn.lock linguist-generated=true 2 | frontend/src/style/semantic-ui-theme.css linguist-vendored=true 3 | -------------------------------------------------------------------------------- /frontend/src/fonts/PT_Sans-Web-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/fonts/PT_Sans-Web-Bold.ttf -------------------------------------------------------------------------------- /frontend/src/images/tip-button-blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/images/tip-button-blue.png -------------------------------------------------------------------------------- /frontend/src/images/tip-button-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/images/tip-button-dark.png -------------------------------------------------------------------------------- /frontend/src/images/tip-button-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/images/tip-button-light.png -------------------------------------------------------------------------------- /frontend/src/fonts/PT_Sans-Web-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/fonts/PT_Sans-Web-Italic.ttf -------------------------------------------------------------------------------- /frontend/src/fonts/PT_Sans-Web-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/fonts/PT_Sans-Web-Regular.ttf -------------------------------------------------------------------------------- /frontend/src/images/tip-button-orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/images/tip-button-orange.png -------------------------------------------------------------------------------- /frontend/src/fonts/PT_Sans-Web-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiphub-io/tiphub/HEAD/frontend/src/fonts/PT_Sans-Web-BoldItalic.ttf -------------------------------------------------------------------------------- /backend/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from boltathon.app import create_app 3 | 4 | os.environ["GRPC_SSL_CIPHER_SUITES"] = 'HIGH+ECDSA' 5 | 6 | app = create_app() 7 | -------------------------------------------------------------------------------- /frontend/src/util/env.ts: -------------------------------------------------------------------------------- 1 | // Any env var added here must also be added in webpack.config.js 2 | const env = { 3 | BACKEND_URL: process.env.BACKEND_URL || '', 4 | }; 5 | 6 | export default env; 7 | -------------------------------------------------------------------------------- /backend/boltathon/templates/emails/template.txt: -------------------------------------------------------------------------------- 1 | {{ args.body }} 2 | 3 | =============== 4 | 5 | Sent from TipHub.io 6 | 7 | Don't want any more emails? Just remove your email from settings: {{ args.unsubscribe_url }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Sneaky secrets 2 | .env 3 | 4 | # Node stuff 5 | node_modules 6 | dist 7 | *.log 8 | webpack-stats.json 9 | 10 | # Python stuff 11 | venv 12 | __pycache__ 13 | 14 | # System file stuff 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /.buildpacks: -------------------------------------------------------------------------------- 1 | frontend=https://github.com/heroku/heroku-buildpack-nodejs.git 2 | https://github.com/eugeneotto/heroku-buildpack-secp256k1 3 | https://github.com/heroku/heroku-buildpack-apt/ 4 | backend=https://github.com/heroku/heroku-buildpack-python.git 5 | -------------------------------------------------------------------------------- /frontend/src/pages/Profile.less: -------------------------------------------------------------------------------- 1 | .Profile { 2 | max-width: 880px; 3 | margin: 0 auto; 4 | 5 | &-loading { 6 | position: relative; 7 | min-height: 50vh; 8 | } 9 | 10 | .ui.secondary.pointing.menu { 11 | font-size: 1.3rem; 12 | margin-bottom: 1.5rem; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/types/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const image: string; 3 | export default image; 4 | } 5 | 6 | declare module '*.jpg' { 7 | const image: string; 8 | export default image; 9 | } 10 | 11 | declare module '*.svg' { 12 | const image: string; 13 | export default image; 14 | } 15 | -------------------------------------------------------------------------------- /backend/boltathon/templates/emails/tip_received.txt: -------------------------------------------------------------------------------- 1 | You just received a tip from {{ args.tip.sender or 'an anonymous user' }} for {{ args.tip.amount }} satoshis. 2 | {% if args.tip.message %} 3 | They also added the following message: 4 | 5 | > {{ args.tip.message }} 6 | {% endif %} 7 | 8 | You can view all of your tips at {{ args.tips_url }} -------------------------------------------------------------------------------- /Aptfile: -------------------------------------------------------------------------------- 1 | build-essential 2 | binutils 3 | cpp-5 4 | libc6 5 | libc6-dev 6 | libcc1-0 7 | libgcc-5-dev 8 | libgcc1 9 | libstdc++6 10 | zlib1g 11 | gcc 12 | gcc-5 13 | gcc-5-base 14 | libmpfr4 15 | libmpfr-dev 16 | libmpc3 17 | libgmp3-dev 18 | libgmp10 19 | libgmp-dev 20 | libmpc-dev 21 | flex 22 | bison 23 | libisl15 24 | libisl-dev 25 | make 26 | -------------------------------------------------------------------------------- /backend/boltathon/util/errors.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify 2 | 3 | class RequestError(Exception): 4 | def __init__(self, message, code=400): 5 | Exception.__init__(self) 6 | self.message = message 7 | self.code = code 8 | 9 | def to_dict(self): 10 | return { 11 | 'message': self.message, 12 | 'code': self.code, 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/style/index.less: -------------------------------------------------------------------------------- 1 | @import './variables.less'; 2 | @import './fonts.less'; 3 | 4 | body { 5 | font-size: 16px; 6 | 7 | @media @lg-query { 8 | font-size: 14px; 9 | } 10 | 11 | @media @md-query { 12 | font-size: 13px; 13 | } 14 | 15 | @media @sm-query { 16 | font-size: 12px; 17 | } 18 | 19 | @media @xs-query { 20 | font-size: 11px; 21 | } 22 | } -------------------------------------------------------------------------------- /backend/boltathon/templates/emails/tip_error.txt: -------------------------------------------------------------------------------- 1 | We tried to generate an invoice for someone to tip you, but were unable to 2 | contact your node. We got back the following error: 3 | 4 | > {{ args.error }} 5 | 6 | You can update your node's connection settings here: {{ args.config_url }} 7 | 8 | If you need help fixing the problem, don't hesitate to post in our GitHub issue queue: {{ args.support_url }} -------------------------------------------------------------------------------- /frontend/src/style/variables.less: -------------------------------------------------------------------------------- 1 | // Colors 2 | @primary-color: #E95420; 3 | 4 | // Screen sizes 5 | @screen-xs: 480px; 6 | @screen-sm: 576px; 7 | @screen-md: 768px; 8 | @screen-lg: 992px; 9 | 10 | // Media queries 11 | @lg-query: ~'(max-width: @{screen-lg})'; 12 | @md-query: ~'(max-width: @{screen-md})'; 13 | @sm-query: ~'(max-width: @{screen-sm})'; 14 | @xs-query: ~'(max-width: @{screen-xs})'; 15 | -------------------------------------------------------------------------------- /backend/boltathon/extensions.py: -------------------------------------------------------------------------------- 1 | from authlib.flask.client import OAuth 2 | from flask_sqlalchemy import SQLAlchemy 3 | from flask_migrate import Migrate 4 | from flask_marshmallow import Marshmallow 5 | from flask_talisman import Talisman 6 | from flask_compress import Compress 7 | 8 | oauth = OAuth() 9 | db = SQLAlchemy() 10 | migrate = Migrate() 11 | ma = Marshmallow() 12 | talisman = Talisman() 13 | compress = Compress() -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es5", 5 | "jsx": "react", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "strict": true, 9 | "noEmitOnError": true, 10 | "preserveConstEnums": true, 11 | "skipLibCheck": true, 12 | "allowSyntheticDefaultImports": true, 13 | "lib": ["dom", "es2017"], 14 | "baseUrl": ".", 15 | }, 16 | "include": ["./src", "./types"] 17 | } 18 | -------------------------------------------------------------------------------- /backend/boltathon/util/__init__.py: -------------------------------------------------------------------------------- 1 | from boltathon.settings import FRONTEND_URL 2 | import random 3 | 4 | def frontend_url(path): 5 | return FRONTEND_URL + path 6 | 7 | def gen_random_id(model): 8 | min_id = 100000 9 | max_id = pow(2, 31) - 1 10 | random_id = random.randint(min_id, max_id) 11 | 12 | # If it already exists, generate a new one (recursively) 13 | existing = model.query.filter_by(id=random_id).first() 14 | if existing: 15 | random_id = gen_random_id(model) 16 | 17 | return random_id 18 | -------------------------------------------------------------------------------- /frontend/src/components/ConnectionsForm.less: -------------------------------------------------------------------------------- 1 | .ConnectionsForm { 2 | max-width: 480px; 3 | margin: 0 auto; 4 | 5 | &-connection { 6 | display: flex; 7 | margin-bottom: 0.5rem; 8 | 9 | &-icon { 10 | opacity: 0.8; 11 | height: 1rem; 12 | width: 1rem; 13 | margin-right: 0.3rem; 14 | } 15 | 16 | .button.purple.ui { 17 | background: #3700ff; 18 | } 19 | } 20 | 21 | &-hint { 22 | text-align: center; 23 | opacity: 0.5; 24 | margin-top: 1.5rem; 25 | font-size: 0.9rem; 26 | } 27 | } -------------------------------------------------------------------------------- /frontend/types/react-syntax-highlighter.d.ts: -------------------------------------------------------------------------------- 1 | // The @types/react-syntax-highlighter module is viciously out of date, so 2 | // here we'll define only what we need 3 | 4 | declare module 'react-syntax-highlighter/dist/esm/light' { 5 | const Light: any; 6 | export default Light; 7 | } 8 | 9 | declare module 'react-syntax-highlighter/dist/esm/languages/hljs/xml' { 10 | const xml: any; 11 | export default xml; 12 | } 13 | 14 | 15 | declare module 'react-syntax-highlighter/dist/styles/hljs/xcode' { 16 | const style: any; 17 | export default style; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | TipHub 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /backend/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | // Style dependencies first so our styles override them 2 | import './style/semantic-ui-theme.css'; 3 | import './style/index.less'; 4 | 5 | import React from 'react'; 6 | import { render } from 'react-dom'; 7 | import { hot } from 'react-hot-loader'; 8 | import { Router } from 'react-router'; 9 | import { createBrowserHistory } from 'history'; 10 | import App from './App'; 11 | 12 | const history = createBrowserHistory(); 13 | 14 | const Container = hot(module)(() => ( 15 | 16 | 17 | 18 | )); 19 | 20 | render( 21 | , 22 | document.getElementById('root'), 23 | ); 24 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.0.2 2 | googleapis-common-protos==1.5.9 3 | grpcio==1.19.0 4 | grpcio-tools==1.19.0 5 | Authlib==0.10 6 | loginpass==0.2.1 7 | python-dotenv==0.10.1 8 | gunicorn>=19.1.1 9 | Flask-SQLAlchemy==2.3.2 10 | SQLAlchemy==1.3.3 11 | Flask-Migrate==2.2.1 12 | psycopg2-binary==2.7.5 13 | marshmallow==3.0.0b13 14 | flask-marshmallow==0.9.0 15 | marshmallow-sqlalchemy==0.16.1 16 | base58==0.2.5 17 | secp256k1==0.13.2 18 | bitstring==3.1.5 19 | webargs==5.2.0 20 | flask-cors==3.0.7 21 | sendgrid==6.0.4 22 | flask-talisman==0.6.0 23 | Flask-Compress==1.4.0 24 | 25 | # JWT package that supports ES256K for blockstack 26 | git+git://github.com/pohutukawa/python-jose@28cd302d#egg=python-jose 27 | -------------------------------------------------------------------------------- /frontend/src/components/ProfileTips.less: -------------------------------------------------------------------------------- 1 | .ProfileTips { 2 | padding: 1rem; 3 | 4 | &-tips.feed { 5 | margin-bottom: 3rem; 6 | 7 | &.is-loading { 8 | opacity: 0.3; 9 | pointer-events: none; 10 | } 11 | 12 | .event { 13 | margin-bottom: 1rem; 14 | 15 | .content { 16 | .date { 17 | font-size: 0.85rem; 18 | } 19 | 20 | .summary { 21 | font-weight: normal; 22 | color: rgba(#000, 0.4); 23 | 24 | strong { 25 | font-weight: 600; 26 | color: rgba(#000, 0.6); 27 | } 28 | } 29 | 30 | .extra.text { 31 | font-size: 1.2rem; 32 | color: rgba(#000, 0.6); 33 | } 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Flask configuration 2 | FLASK_APP=app.py 3 | FLASK_ENV=development 4 | SECRET_KEY="PLEASE CHANGE ME" 5 | DATABASE_URL="postgresql://username:password@localhost:5432/db-name" 6 | 7 | # Set this if the backend and frontend are on different domains. This shouldn't 8 | # need to be set in production, since flask should be serving up the compiled 9 | # frontend, but development will need it with webpack-dev-server. 10 | BACKEND_URL="http://localhost:5000" 11 | FRONTEND_URL="http://localhost:8080" 12 | 13 | # Fixed URL public path, required for correct Blockstack links. Only used in 14 | # production builds of the frontend. 15 | PUBLIC_PATH="http://localhost:5000" 16 | 17 | # OAuth Credentials 18 | GITHUB_CLIENT_ID="123..." 19 | GITHUB_CLIENT_SECRET="abc..." 20 | 21 | GITLAB_CLIENT_ID="123..." 22 | GITLAB_CLIENT_SECRET="abc..." 23 | 24 | # SendGrid Credentials. Optional, emails won't send without them. 25 | SENDGRID_API_KEY="123..." -------------------------------------------------------------------------------- /backend/boltathon/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | ENV = os.getenv('FLASK_ENV', default='production') 7 | DEBUG = ENV == 'development' 8 | SECRET_KEY = os.getenv('SECRET_KEY') 9 | FRONTEND_URL = '' if ENV == 'production' else os.getenv('FRONTEND_URL') 10 | 11 | SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL") 12 | SQLALCHEMY_ECHO = False # True will print queries to log 13 | SQLALCHEMY_TRACK_MODIFICATIONS = False 14 | 15 | GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID') 16 | GITHUB_CLIENT_SECRET = os.getenv('GITHUB_CLIENT_SECRET') 17 | GITLAB_CLIENT_ID = os.getenv('GITLAB_CLIENT_ID') 18 | GITLAB_CLIENT_SECRET = os.getenv('GITLAB_CLIENT_SECRET') 19 | 20 | SENDGRID_API_KEY = os.getenv('SENDGRID_API_KEY') 21 | SENDGRID_DEFAULT_FROM = 'noreply@tiphub.io' 22 | SENDGRID_DEFAULT_FROMNAME = 'TipHub' 23 | 24 | UI = { 25 | 'NAME': 'TipHub', 26 | 'PRIMARY': '#E85420', 27 | 'SECONDARY': '#333333' 28 | } 29 | -------------------------------------------------------------------------------- /frontend/src/style/fonts.less: -------------------------------------------------------------------------------- 1 | /* Regular */ 2 | @font-face { 3 | font-family: 'PT Sans'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: local('PT Sans'), local('PTSans-Regular'), url('../fonts/PT_Sans-Web-Regular.ttf') format('ttf'); 7 | } 8 | 9 | /* Bold */ 10 | @font-face { 11 | font-family: 'PT Sans'; 12 | font-style: normal; 13 | font-weight: 700; 14 | src: local('PT Sans Bold'), local('PTSans-Bold'), url('../fonts/PT_Sans-Web-Italic.ttf') format('ttf'); 15 | } 16 | 17 | /* Italic */ 18 | @font-face { 19 | font-family: 'PT Sans'; 20 | font-style: italic; 21 | font-weight: 400; 22 | src: local('PT Sans Italic'), local('PTSans-Italic'), url('../fonts/PT_Sans-Web-Bold.ttf') format('ttf'); 23 | } 24 | 25 | /* Bold Italic */ 26 | @font-face { 27 | font-family: 'PT Sans'; 28 | font-style: italic; 29 | font-weight: 700; 30 | src: local('PT Sans Bold Italic'), local('PTSans-BoldItalic'), url('../fonts/PT_Sans-Web-BoldItalic.ttf') format('ttf'); 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/pages/ProfileSetup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter, RouteComponentProps } from 'react-router'; 3 | import NodeForm from '../components/NodeForm'; 4 | import api, { User } from '../api'; 5 | 6 | type Props = RouteComponentProps<{ userid: string }>; 7 | 8 | interface State { 9 | user: User | null; 10 | } 11 | 12 | class ProfileSetup extends React.Component { 13 | state: State = { 14 | user: null, 15 | }; 16 | 17 | async componentDidMount() { 18 | try { 19 | const user = await api.getSelf(); 20 | this.setState({ user }); 21 | } catch(err) { 22 | alert(err); 23 | } 24 | } 25 | 26 | render() { 27 | const { history } = this.props; 28 | const { user } = this.state; 29 | return ( 30 | history.replace('/user/me')} 33 | /> 34 | ); 35 | } 36 | }; 37 | 38 | export default withRouter(ProfileSetup); 39 | -------------------------------------------------------------------------------- /backend/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /backend/boltathon/util/auth.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from flask import g, session, current_app 3 | from boltathon.models.user import User 4 | from .errors import RequestError 5 | 6 | def requires_auth(f): 7 | @wraps(f) 8 | def decorated(*args, **kwargs): 9 | user = get_authed_user() 10 | if not user: 11 | raise RequestError(code=403, message='Must be logged in to do that') 12 | else: 13 | return f(*args, **kwargs) 14 | return decorated 15 | 16 | def get_authed_user(): 17 | # If we've already done this, early exit 18 | if g.get('current_user'): 19 | return g.get('current_user') 20 | # Check if they have user_id in session 21 | if not session or not session.get('user_id'): 22 | return False 23 | # Check that the user is forreal 24 | user = User.query.filter_by(id=session.get('user_id')).first() 25 | if not user: 26 | session['user_id'] = None 27 | return False 28 | # Set it to the global context and return it 29 | g.current_user = user 30 | return user 31 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route } from 'react-router'; 3 | import Home from './pages/Home'; 4 | import About from './pages/About'; 5 | import Profile from './pages/Profile'; 6 | import ProfileSetup from './pages/ProfileSetup'; 7 | import BlockstackAuth from './pages/BlockstackAuth'; 8 | import Tip from './pages/Tip'; 9 | import Template from './components/Template'; 10 | 11 | export default class App extends React.Component { 12 | render() { 13 | return ( 14 | 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/ProfileHeader.less: -------------------------------------------------------------------------------- 1 | .ProfileHeader { 2 | display: flex; 3 | align-items: center; 4 | margin-bottom: 2rem; 5 | 6 | &-image { 7 | width: 8rem; 8 | height: 8rem; 9 | flex-shrink: 0; 10 | margin-right: 2rem; 11 | border-radius: 100%; 12 | overflow: hidden; 13 | 14 | img { 15 | height: 100%; 16 | width: 100%; 17 | } 18 | 19 | .placeholder, 20 | .placeholder .image { 21 | height: 100% !important; 22 | } 23 | } 24 | 25 | &-info { 26 | width: 100%; 27 | 28 | &-name { 29 | font-size: 1.8rem; 30 | margin-bottom: 0.75rem; 31 | } 32 | 33 | &-pubkey { 34 | font-size: 0.8rem; 35 | opacity: 0.4; 36 | margin-bottom: 0.5rem; 37 | } 38 | 39 | &-connections { 40 | display: flex; 41 | align-items: center; 42 | font-size: 1rem; 43 | opacity: 0.7; 44 | 45 | &:hover { 46 | opacity: 1; 47 | } 48 | } 49 | 50 | .placeholder { 51 | transform: translateY(-10%); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /backend/migrations/versions/0b3fa2e55459_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 0b3fa2e55459 4 | Revises: 87a598d296cb 5 | Create Date: 2019-04-07 04:07:48.981739 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '0b3fa2e55459' 14 | down_revision = '87a598d296cb' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('tip', sa.Column('payment_request', sa.String(length=255), nullable=False)) 22 | op.alter_column('tip', 'amount', 23 | existing_type=sa.INTEGER(), 24 | nullable=True) 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade(): 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | op.alter_column('tip', 'amount', 31 | existing_type=sa.INTEGER(), 32 | nullable=False) 33 | op.drop_column('tip', 'payment_request') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /backend/boltathon/util/blockstack.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from jose import jwt 3 | from flask import current_app 4 | from boltathon.util.errors import RequestError 5 | 6 | # Given a Blockstack private key, validate that it was signed by the same 7 | # key as owns a username 8 | def validate_blockstack_auth(id, username, token): 9 | try: 10 | # Match up data with arguments 11 | token = jwt.decode(token, [], options={ 'verify_signature': False }) 12 | if username != token.get('username'): 13 | return False 14 | if id not in token.get('profile_url'): 15 | return False 16 | 17 | # Match up data with Blockstack's atlas node 18 | res = requests.get(f'https://core.blockstack.org/v1/names/{username}') 19 | res = res.json() 20 | if res.get('address') != id: 21 | return False 22 | 23 | return True 24 | except Exception as e: 25 | print(e) 26 | current_app.logger.error(f'Could not lookup username {username} from blockstack.org: {e}') 27 | raise RequestError(code=500, message='Failed to lookup Blockstack identity') 28 | -------------------------------------------------------------------------------- /frontend/src/util/constants.ts: -------------------------------------------------------------------------------- 1 | import { SemanticICONS } from 'semantic-ui-react'; 2 | import { ConnectionSite, Connection } from '../api'; 3 | 4 | export const CONNECTION_UI = { 5 | [ConnectionSite.github]: { 6 | name: 'GitHub', 7 | color: '#333', 8 | icon: 'github' as SemanticICONS, 9 | url: (c: Connection) => `https://github.com/${c.site_username}`, 10 | img: (c: Connection) => `https://github.com/${c.site_username}.png`, 11 | }, 12 | [ConnectionSite.gitlab]: { 13 | name: 'GitLab', 14 | color: '#fc6d26', 15 | icon: 'gitlab' as SemanticICONS, 16 | url: (c: Connection) => `https://gitlab.com/${c.site_username}`, 17 | img: (_: Connection) => 'https://assets.gitlab-static.net/assets/touch-icon-iphone-retina-72e2aadf86513a56e050e7f0f2355deaa19cc17ed97bbe5147847f2748e5a3e3.png', 18 | }, 19 | [ConnectionSite.blockstack]: { 20 | name: 'Blockstack', 21 | color: '#3700ff', 22 | icon: 'block layout' as SemanticICONS, 23 | url: (_: Connection) => `https://browser.blockstack.org`, 24 | img: (c: Connection) => `https://gaia.blockstack.org/hub/${c.site_id}//avatar-0`, // not a typo, two slashes 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/util/formatters.ts: -------------------------------------------------------------------------------- 1 | import { Connection, ConnectionSite } from '../api'; 2 | 3 | export function makeBackendUrl(path: string) { 4 | return `${process.env.BACKEND_URL || ''}${path}`; 5 | } 6 | 7 | export function makeConnectionUrl(c: Connection): string { 8 | const urls = { 9 | [ConnectionSite.github]: `https://github.com/${c.site_username}`, 10 | [ConnectionSite.gitlab]: `https://gitlab.com/${c.site_username}`, 11 | [ConnectionSite.blockstack]: `https://gaia.blockstack.org/hub/${c.site_id}/profile.json`, 12 | }; 13 | return urls[c.site]; 14 | } 15 | 16 | export function blobToString(blob: Blob, format: 'hex' | 'base64'): Promise { 17 | return new Promise((resolve, reject) => { 18 | try { 19 | const reader = new FileReader(); 20 | reader.addEventListener('load', () => { 21 | if (reader.result) { 22 | const str = Buffer.from(reader.result as string, 'binary').toString(format) 23 | resolve(str); 24 | } else { 25 | reject(new Error('File could not be read')); 26 | } 27 | }); 28 | reader.readAsBinaryString(blob); 29 | } catch(err) { 30 | reject(err); 31 | } 32 | }); 33 | } -------------------------------------------------------------------------------- /backend/boltathon/templates/emails/tip_received.html: -------------------------------------------------------------------------------- 1 |

2 | You just received a tip from 3 | {{args.tip.sender or 'an anonymous user'}} 4 | for 5 | {{ args.tip.amount }} sats! 6 | {% if args.tip.message %} 7 | They also added the following message: 8 | {% endif %} 9 |

10 | 11 | {% if args.tip.message %} 12 |

13 | “{{ args.tip.message }}” 14 |

15 | {% endif %} 16 | 17 | 18 | 19 | 31 | 32 |
20 | 21 | 22 | 28 | 29 |
23 | 25 | View your Tips 26 | 27 |
30 |
33 | -------------------------------------------------------------------------------- /frontend/src/components/Template.less: -------------------------------------------------------------------------------- 1 | @import '../style//variables.less'; 2 | 3 | .Template { 4 | display: flex; 5 | flex-direction: column; 6 | min-height: 100vh; 7 | 8 | &-header, 9 | &-content, 10 | &-footer { 11 | width: 100%; 12 | padding-left: 2rem; 13 | padding-right: 2rem; 14 | 15 | &-inner { 16 | max-width: 1120px; 17 | margin: 0 auto; 18 | } 19 | } 20 | 21 | &-header { 22 | flex-shrink: 0; 23 | margin-bottom: 3rem; 24 | 25 | &-inner { 26 | display: flex; 27 | justify-content: space-between; 28 | align-items: center; 29 | padding: 1.25rem 0; 30 | } 31 | 32 | &-title img { 33 | display: block; 34 | height: 2.6rem; 35 | } 36 | 37 | &-menu { 38 | a { 39 | padding: 0 0.1rem; 40 | margin-left: 1rem; 41 | font-size: 1.35rem; 42 | color: #333; 43 | opacity: 0.3; 44 | 45 | &:hover { 46 | color: @primary-color; 47 | opacity: 1; 48 | } 49 | } 50 | } 51 | } 52 | 53 | &-content { 54 | flex: 1; 55 | padding-bottom: 4rem; 56 | } 57 | 58 | &-footer { 59 | padding: 2rem; 60 | text-align: center; 61 | background: rgba(0, 0, 33, 0.05); 62 | 63 | p { 64 | opacity: 0.7; 65 | margin-bottom: 1rem; 66 | 67 | &:last-child { 68 | margin-bottom: 0; 69 | } 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /frontend/src/pages/Tip.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Loader } from 'semantic-ui-react' 3 | import { withRouter, RouteComponentProps } from 'react-router'; 4 | import api, { User, SelfUser } from '../api'; 5 | import TipForm from '../components/TipForm'; 6 | 7 | type Props = RouteComponentProps<{ userid: string }>; 8 | 9 | interface State { 10 | user: User | SelfUser | undefined; 11 | error: string; 12 | } 13 | 14 | class Tip extends React.Component { 15 | state: State = { 16 | user: undefined, 17 | error: '', 18 | }; 19 | 20 | async componentDidMount() { 21 | const { userid } = this.props.match.params; 22 | try { 23 | const user = await api.getUser(parseInt(userid, 10)); 24 | this.setState({ user }); 25 | } catch(err) { 26 | this.setState({ error: err.message }); 27 | } 28 | } 29 | 30 | render() { 31 | const { user, error } = this.state; 32 | 33 | if (error) { 34 | return error; 35 | } 36 | 37 | let content; 38 | if (!user) { 39 | content = ( 40 |
41 | Loading... 42 |
43 | ); 44 | } 45 | else { 46 | content = 47 | } 48 | 49 | return ( 50 |
51 | {content} 52 |
53 | ); 54 | } 55 | }; 56 | 57 | export default withRouter(Tip); 58 | -------------------------------------------------------------------------------- /frontend/src/pages/Home.less: -------------------------------------------------------------------------------- 1 | @import '../style/variables.less'; 2 | 3 | .Home { 4 | display: flex; 5 | align-items: flex-start; 6 | min-height: 40vh; 7 | padding-top: 10vh; 8 | margin-bottom: 5rem; 9 | max-width: 1020px; 10 | margin: 0 auto; 11 | 12 | &-info, 13 | &-forms { 14 | width: 100%; 15 | } 16 | 17 | &-info { 18 | padding-top: 2rem; 19 | padding-right: 8rem; 20 | 21 | &-title { 22 | font-size: 2.6rem; 23 | } 24 | 25 | &-text { 26 | font-size: 1.6rem; 27 | } 28 | } 29 | 30 | &-forms { 31 | max-width: 400px; 32 | text-align: center; 33 | 34 | &-start { 35 | .button { 36 | margin-top: 0.5rem; 37 | 38 | &.purple.ui { 39 | background: #3700ff; 40 | } 41 | } 42 | 43 | &-icon { 44 | width: 1rem; 45 | height: 1rem; 46 | margin-right: 0.4rem; 47 | opacity: 0.8; 48 | } 49 | } 50 | 51 | &-search { 52 | .input { 53 | width: 100%; 54 | } 55 | } 56 | } 57 | 58 | @media @lg-query { 59 | flex-direction: column; 60 | justify-content: center; 61 | align-items: center; 62 | max-width: 600px; 63 | margin: 0 auto 5rem; 64 | padding-top: 3rem; 65 | 66 | &-info { 67 | margin-bottom: 5rem; 68 | padding-top: 0; 69 | padding-right: 0; 70 | } 71 | 72 | &-forms { 73 | max-width: none; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /backend/boltathon/templates/emails/tip_error.html: -------------------------------------------------------------------------------- 1 |

2 | We tried to generate an invoice for someone to tip you, but were unable to 3 | contact your node. We got back the following error: 4 |

5 | 6 |

7 | “{{ args.error }}” 8 |

9 | 10 |

11 | You can update your node's connection settings below. If you need help 12 | fixing the problem, don't hesitate to post in our 13 | GitHub issue queue. 14 |

15 | 16 | 17 | 18 | 30 | 31 |
19 | 20 | 21 | 27 | 28 |
22 | 24 | Change Node Config 25 | 26 |
29 |
32 | -------------------------------------------------------------------------------- /frontend/src/pages/BlockstackAuth.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Loader, Message } from 'semantic-ui-react'; 3 | import { withRouter, RouteComponentProps } from 'react-router'; 4 | import * as blockstack from 'blockstack.js'; 5 | import { UserData } from 'blockstack.js/lib/auth/authApp'; 6 | import api from '../api'; 7 | 8 | interface State { 9 | error: string | null; 10 | } 11 | 12 | class BlockstackAuth extends React.Component { 13 | state: State = { 14 | error: null, 15 | }; 16 | 17 | componentDidMount() { 18 | if (blockstack.isUserSignedIn()) { 19 | this.auth(blockstack.loadUserData()); 20 | } else if (blockstack.isSignInPending()) { 21 | blockstack.handlePendingSignIn().then(this.auth); 22 | } 23 | } 24 | 25 | render() { 26 | const { error } = this.state; 27 | 28 | if (error) { 29 | return ( 30 | 31 | Failed to authenticate with Blockstack 32 | {error} 33 | 34 | ); 35 | } 36 | 37 | return Connecting to Blockstack...; 38 | } 39 | 40 | private auth = (data: UserData) => { 41 | const { history } = this.props; 42 | api.blockstackAuth(data).then(res => { 43 | history.replace('/user/me'); 44 | }).catch(err => { 45 | this.setState({ error: err.message }); 46 | }); 47 | }; 48 | }; 49 | 50 | export default withRouter(BlockstackAuth); 51 | -------------------------------------------------------------------------------- /frontend/src/components/EmbedForm.less: -------------------------------------------------------------------------------- 1 | .EmbedForm { 2 | &-form { 3 | display: flex; 4 | align-items: flex-end; 5 | margin-bottom: 1rem; 6 | 7 | &.ui .field { 8 | margin: 0 1rem 0 0; 9 | } 10 | } 11 | 12 | &-embed { 13 | display: flex; 14 | flex-direction: column-reverse; 15 | border: 1px solid rgba(#000, 0.2); 16 | border-radius: 4px; 17 | 18 | &-code, 19 | &-preview { 20 | overflow: auto; 21 | } 22 | 23 | &-code { 24 | border-top: 1px solid rgba(#000, 0.2); 25 | 26 | pre { 27 | margin: 0; 28 | padding: 1.25rem 1rem; 29 | } 30 | } 31 | 32 | &-preview { 33 | display: flex; 34 | justify-content: center; 35 | align-items: center; 36 | padding: 3rem 0; 37 | 38 | // Styles from GitHub 39 | font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol; 40 | font-size: 16px; 41 | line-height: 1.5; 42 | word-wrap: break-word; 43 | 44 | a { 45 | color: #0366d6; 46 | text-decoration: none; 47 | 48 | &:hover { 49 | outline-width: 0; 50 | text-decoration: underline; 51 | } 52 | } 53 | 54 | code { 55 | font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,Courier,monospace; 56 | background-color: rgba(27,31,35,.05); 57 | border-radius: 3px; 58 | font-size: 85%; 59 | margin: 0; 60 | padding: .2em .4em; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /frontend/src/components/Template.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import TipHubLogo from '../images/logo.svg'; 4 | import './Template.less'; 5 | 6 | 7 | interface Props { 8 | children: React.ReactNode; 9 | } 10 | 11 | export default class Template extends React.Component { 12 | render() { 13 | const { children } = this.props; 14 | 15 | return ( 16 |
17 |
18 |
19 | 20 |

21 | TipHub 22 |

23 | 24 |
25 | About 26 | Account 27 |
28 |
29 |
30 |
31 |
32 | {children} 33 |
34 |
35 |
36 |
37 |

38 | TipHub is fully open source on{' '} 39 | GitHub 40 |

41 |

42 | Made with ❤️and ⚡️ 43 |

44 |
45 |
46 |
47 | ); 48 | } 49 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TipHub 2 | 3 | Send sats to your favorite open source contributors! Embed a button in your README and get tips for your work. See an example of that right here: 4 | 5 |

6 | 7 | Tip wbobeirne on TipHub 8 |
9 | My pubkey starts with 02458b08 10 |
11 |

12 | 13 | ## Development 14 | 15 | First setup your environment variables, copy `.env.example` into a file named `.env`. Review each one and follow the instructions for the ones that need input. 16 | 17 | To start the backend: 18 | * `cd backend` 19 | * Setup virtualenv (recommended, not required) 20 | * `virtualenv -p python3 venv` 21 | * `source venv/bin/activate` 22 | * `pip install -r requirements.txt` 23 | * `flask db upgrade` 24 | * `FLASK_APP=app.py FLASK_ENV=development flask run` 25 | 26 | To start the frontend: 27 | * `cd frontend` 28 | * `yarn && yarn dev` 29 | 30 | ## Production 31 | 32 | * Build the frontend with 'yarn && yarn build' 33 | * Run `FLASK_APP=app.py FLASK_ENV=production flask run` 34 | 35 | ## Deploy 36 | 37 | This app was built to deploy on heroku by doing the following: 38 | 39 | * Create a new heroku app and link it to the repo 40 | * Setup your environment variables based on `.env.example` 41 | * Provision a PostgreSQL database addon 42 | * Add the [subdir buildpack](https://elements.heroku.com/buildpacks/pagedraw/heroku-buildpack-select-subdir) 43 | * `heroku buildpacks:set https://github.com/negativetwelve/heroku-buildpack-subdir` 44 | * `git push heroku master` 45 | -------------------------------------------------------------------------------- /backend/boltathon/views/oauth.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from flask import Blueprint, url_for, session, redirect 3 | from loginpass import create_flask_blueprint, GitHub, Gitlab 4 | from boltathon.models.user import User 5 | from boltathon.models.connection import Connection 6 | from boltathon.extensions import oauth, db 7 | from boltathon.util import frontend_url 8 | from boltathon.util.errors import RequestError 9 | from boltathon.util.auth import get_authed_user 10 | 11 | blueprint = Blueprint("oauth", __name__, url_prefix="/oauth") 12 | 13 | def handle_authorize(remote, token, user_info): 14 | if not token or not user_info: 15 | raise RequestError(code=400, message="Missing OAuth token or user info") 16 | 17 | # Find existing user, add to logged in user, or create a new user 18 | user = Connection.get_user_by_connection( 19 | site=remote.name, 20 | site_id=user_info['preferred_username'] 21 | ) 22 | if not user: 23 | user = get_authed_user() 24 | if not user: 25 | user = User() 26 | connection = Connection( 27 | userid=user.id, 28 | site=remote.name, 29 | site_id=user_info['preferred_username'], 30 | site_username=user_info['preferred_username'], 31 | ) 32 | user.connections.append(connection) 33 | db.session.add(user) 34 | db.session.add(connection) 35 | db.session.commit() 36 | 37 | # Set them as logged in in the session 38 | session['user_id'] = user.id 39 | redirect_url = frontend_url('/user/me') 40 | return redirect(redirect_url) 41 | 42 | github_blueprint = create_flask_blueprint(GitHub, oauth, handle_authorize) 43 | gitlab_blueprint = create_flask_blueprint(Gitlab, oauth, handle_authorize) 44 | -------------------------------------------------------------------------------- /frontend/src/images/blockstack.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /backend/boltathon/models/tip.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from boltathon.extensions import ma, db 3 | from boltathon.util import gen_random_id, frontend_url 4 | from boltathon.util.mail import send_email 5 | 6 | class Tip(db.Model): 7 | __tablename__ = 'tip' 8 | 9 | id = db.Column(db.Integer(), primary_key=True) 10 | date_created = db.Column(db.DateTime) 11 | 12 | receiver_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) 13 | sender = db.Column(db.String(63), nullable=True) 14 | message = db.Column(db.String(255), nullable=True) 15 | repo = db.Column(db.String(63), nullable=True) 16 | amount = db.Column(db.Integer(), nullable=True) 17 | payment_request = db.Column(db.String(255), nullable=False) 18 | rhash = db.Column(db.String(127), nullable=False) 19 | 20 | def __init__( 21 | self, 22 | receiver_id: int, 23 | sender: str, 24 | message: str, 25 | payment_request: str, 26 | rhash: str, 27 | ): 28 | self.id = gen_random_id(Tip) 29 | self.receiver_id = receiver_id 30 | self.sender = sender 31 | self.message = message 32 | self.payment_request = payment_request 33 | self.rhash = rhash 34 | self.date_created = datetime.now() 35 | 36 | def confirm(self, amount): 37 | self.amount = amount 38 | db.session.add(self) 39 | db.session.flush() 40 | send_email(self.recipient, 'tip_received', { 41 | 'tip': self, 42 | 'tips_url': frontend_url('/user/me') 43 | }) 44 | 45 | 46 | class TipSchema(ma.Schema): 47 | class Meta: 48 | model = Tip 49 | # Fields to expose 50 | fields = ( 51 | "id", 52 | "date_created", 53 | "sender", 54 | "message", 55 | "repo", 56 | "amount", 57 | "payment_request", 58 | ) 59 | 60 | tip_schema = TipSchema() 61 | tips_schema = TipSchema(many=True) 62 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "boltathon", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "webpack-dev-server", 8 | "build": "rimraf dist && NODE_ENV=production webpack" 9 | }, 10 | "dependencies": { 11 | "blockstack.js": "git://github.com/wbobeirne/blockstack.js#efdbc848f8bd73905e0ed6b98b48ca7e1645900f", 12 | "css-loader": "2.1.1", 13 | "dotenv": "7.0.0", 14 | "dotenv-webpack": "1.7.0", 15 | "favicons-webpack-plugin": "0.0.9", 16 | "file-loader": "3.0.1", 17 | "forest-themes-css": "0.2.0", 18 | "history": "4.9.0", 19 | "html-webpack-plugin": "3.2.0", 20 | "less": "3.9.0", 21 | "less-loader": "4.1.0", 22 | "mini-css-extract-plugin": "0.5.0", 23 | "moment": "2.24.0", 24 | "qrcode.react": "0.9.3", 25 | "query-string": "6.4.2", 26 | "react": "16.8.6", 27 | "react-copy-to-clipboard": "5.0.1", 28 | "react-dom": "16.8.6", 29 | "react-hot-loader": "4.8.3", 30 | "react-router": "5.0.0", 31 | "react-router-dom": "5.0.0", 32 | "react-syntax-highlighter": "10.2.1", 33 | "semantic-ui-css": "2.4.1", 34 | "semantic-ui-react": "0.86.0", 35 | "style-loader": "0.23.1", 36 | "ts-loader": "5.3.3", 37 | "typescript": "3.4.2", 38 | "url-loader": "1.1.2", 39 | "webpack": "4.29.6", 40 | "webpack-cli": "3.3.0", 41 | "webpack-dev-server": "3.2.1", 42 | "webpack-pwa-manifest": "4.0.0" 43 | }, 44 | "devDependencies": { 45 | "@types/dotenv": "6.1.1", 46 | "@types/node": "11.13.0", 47 | "@types/qrcode.react": "0.8.2", 48 | "@types/react": "16.8.12", 49 | "@types/react-copy-to-clipboard": "4.2.6", 50 | "@types/react-dom": "16.8.3", 51 | "@types/react-router": "4.4.5", 52 | "@types/react-router-dom": "4.3.1", 53 | "optimize-css-assets-webpack-plugin": "5.0.1", 54 | "rimraf": "2.6.3", 55 | "terser-webpack-plugin": "1.2.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/pages/About.less: -------------------------------------------------------------------------------- 1 | @import '../style/variables.less'; 2 | 3 | .About { 4 | max-width: 780px; 5 | margin: 0 auto; 6 | 7 | &-section { 8 | display: flex; 9 | margin-bottom: 5rem; 10 | padding-bottom: 5rem; 11 | border-bottom: 1px solid rgba(#000, 0.1); 12 | 13 | @media @md-query { 14 | flex-direction: column; 15 | } 16 | 17 | &:last-child { 18 | border: none; 19 | } 20 | 21 | &-label { 22 | font-size: 2rem; 23 | line-height: 1; 24 | flex-shrink: 0; 25 | width: 20rem; 26 | font-weight: 100; 27 | text-align: right; 28 | padding-right: 5rem; 29 | opacity: 0.5; 30 | 31 | @media @md-query { 32 | width: auto; 33 | padding-right: 0; 34 | padding-bottom: 2rem; 35 | text-align: center; 36 | } 37 | } 38 | 39 | &-content { 40 | flex: 1; 41 | font-size: 1.4rem; 42 | 43 | &-people { 44 | position: relative; 45 | padding-top: 3rem; 46 | margin-left: -15rem; 47 | display: flex; 48 | z-index: 1; 49 | 50 | @media @md-query { 51 | margin: 0; 52 | flex-direction: column; 53 | } 54 | } 55 | } 56 | } 57 | } 58 | 59 | .AboutPerson { 60 | display: flex; 61 | flex-direction: column; 62 | justify-content: flex-start; 63 | align-items: center; 64 | text-align: center; 65 | flex: 1; 66 | padding: 1rem; 67 | 68 | @media @md-query { 69 | margin-bottom: 2rem; 70 | } 71 | 72 | &-image { 73 | width: 12rem; 74 | height: 12rem; 75 | margin-bottom: 2rem; 76 | border-radius: 100%; 77 | } 78 | 79 | &-name { 80 | font-size: 1.8rem; 81 | margin-bottom: 0.75rem; 82 | } 83 | 84 | &-blurb { 85 | font-size: 1.2rem; 86 | line-height: 1.4; 87 | opacity: 0.7; 88 | margin-bottom: 1rem; 89 | min-height: 70px; 90 | } 91 | 92 | &-buttons { 93 | .button { 94 | margin: 0 0.25rem; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /frontend/src/components/UserSearch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Search, SearchProps, SearchResultData } from 'semantic-ui-react'; 3 | import api, { User } from '../api'; 4 | import { CONNECTION_UI } from '../util/constants'; 5 | import { withRouter, RouteComponentProps } from 'react-router'; 6 | 7 | interface State { 8 | value: string; 9 | results: Object[]; 10 | isLoading: boolean; 11 | } 12 | 13 | class UserSearch extends React.Component { 14 | state: State = { 15 | value: '', 16 | results: [], 17 | isLoading: false, 18 | }; 19 | 20 | render() { 21 | return ( 22 | 30 | ); 31 | } 32 | 33 | private handleSearch = (_: any, data: SearchProps) => { 34 | const value = data.value || ''; 35 | this.setState({ value, isLoading: true }); 36 | 37 | if (!value) { 38 | this.setState({ results: [], isLoading: false }); 39 | return; 40 | } 41 | 42 | api.searchUsers(value).then(connections => { 43 | if (this.state.value !== value) return; 44 | const results = connections.map(c => ({ 45 | // Display 46 | key: `${c.site} ${c.site_username}`, 47 | title: c.site_username, 48 | description: CONNECTION_UI[c.site].name, 49 | image: CONNECTION_UI[c.site].img(c), 50 | // Data for handler 51 | userid: c.user.id, 52 | site: c.site, 53 | })); 54 | this.setState({ results, isLoading: false }); 55 | }).catch(err => { 56 | console.error(err); 57 | this.setState({ results: [], isLoading: false }); 58 | }); 59 | }; 60 | 61 | private goToTip = (_: any, data: SearchResultData) => { 62 | this.props.history.push(`/user/${data.result.userid}/tip?site=${data.result.site}`); 63 | }; 64 | } 65 | 66 | export default withRouter(UserSearch); 67 | -------------------------------------------------------------------------------- /frontend/src/components/ProfileHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { User } from '../api'; 3 | import { Placeholder, Icon, Button } from 'semantic-ui-react'; 4 | import { CONNECTION_UI } from '../util/constants'; 5 | import './ProfileHeader.less'; 6 | 7 | interface Props { 8 | user?: User; 9 | } 10 | 11 | export default class ProfileHeader extends React.Component { 12 | render() { 13 | const { user } = this.props; 14 | 15 | let image, info; 16 | if (user) { 17 | const primaryConnection = user.connections[0]; 18 | image = ; 19 | info = ( 20 | <> 21 |
22 | {primaryConnection.site_username} 23 |
24 |
25 | {user.pubkey} 26 |
27 |
28 | {user.connections.map(c => ( 29 | 38 | ))} 39 |
40 | 41 | ); 42 | } 43 | else { 44 | image = ; 45 | info = ( 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | } 53 | 54 | return ( 55 |
56 |
57 | {image} 58 |
59 |
60 | {info} 61 |
62 |
63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /backend/boltathon/models/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from boltathon.extensions import ma, db 3 | from boltathon.util import gen_random_id 4 | 5 | class User(db.Model): 6 | __tablename__ = 'user' 7 | 8 | id = db.Column(db.Integer(), primary_key=True) 9 | date_created = db.Column(db.DateTime) 10 | email = db.Column(db.String(255), nullable=True) 11 | macaroon = db.Column(db.String(2047), nullable=True) 12 | cert = db.Column(db.String(4095), nullable=True) 13 | node_url = db.Column(db.String(255), nullable=True) 14 | pubkey = db.Column(db.String(255), nullable=True) 15 | 16 | connections = db.relationship('Connection', backref='user', order_by="asc(Connection.date_created)", lazy=True, cascade='all, delete-orphan') 17 | tips = db.relationship('Tip', backref='recipient', lazy=True, cascade='all, delete-orphan') 18 | 19 | def __init__(self): 20 | self.id = gen_random_id(User) 21 | self.date_created = datetime.now() 22 | 23 | @staticmethod 24 | def search_by_connection(query: str): 25 | from boltathon.models.connection import Connection 26 | return User.query \ 27 | .join(Connection) \ 28 | .filter(Connection.site_username.ilike('%{}%'.format(query))) \ 29 | .limit(5) \ 30 | .all() 31 | 32 | # Limited data (public view) 33 | class PublicUserSchema(ma.Schema): 34 | class Meta: 35 | model = User 36 | # Fields to expose 37 | fields = ( 38 | 'id', 39 | 'pubkey', 40 | 'connections', 41 | ) 42 | 43 | connections = ma.Nested('PublicConnectionSchema', many=True, exclude=['user']) 44 | 45 | public_user_schema = PublicUserSchema() 46 | public_users_schema = PublicUserSchema(many=True) 47 | 48 | # Full data (self view) 49 | class SelfUserSchema(ma.Schema): 50 | class Meta: 51 | model = User 52 | # Fields to expose 53 | fields = ( 54 | 'id', 55 | 'date_created', 56 | 'email', 57 | 'macaroon', 58 | 'cert', 59 | 'node_url', 60 | 'pubkey', 61 | 'connections', 62 | ) 63 | 64 | connections = ma.Nested('SelfConnectionSchema', many=True, exclude=['user']) 65 | 66 | self_user_schema = SelfUserSchema() 67 | self_users_schema = SelfUserSchema(many=True) 68 | -------------------------------------------------------------------------------- /frontend/src/components/NodeForm.less: -------------------------------------------------------------------------------- 1 | @import '../style/variables.less'; 2 | 3 | .NodeForm { 4 | display: flex; 5 | max-width: 1020px; 6 | margin: 0 auto; 7 | 8 | &-form { 9 | flex: 1; 10 | 11 | &.ui.form .field { 12 | margin-bottom: 2rem; 13 | } 14 | 15 | &.ui.form &-label { 16 | display: flex; 17 | align-items: center; 18 | 19 | &-help.ui.button { 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | padding: 0; 24 | width: 1.4rem; 25 | height: 1.4rem; 26 | margin-left: 0.25rem; 27 | font-size: 0.7rem; 28 | border-radius: 100%; 29 | opacity: 0.5; 30 | transform: translate(-10%, -20%) scale(0.7); 31 | 32 | &:hover { 33 | opacity: 1; 34 | } 35 | } 36 | } 37 | 38 | &-files { 39 | display: flex; 40 | 41 | .field { 42 | flex: 1; 43 | 44 | &:first-child { 45 | margin-right: 1rem; 46 | } 47 | 48 | .disabled.input { 49 | opacity: 1; 50 | 51 | :disabled { 52 | background: rgba(0, 0, 0, 0.05); 53 | border-color: rgba(0, 0, 0, 0.1); 54 | } 55 | } 56 | } 57 | .button { 58 | height: 100%; 59 | border-top-left-radius: 0; 60 | border-bottom-left-radius: 0; 61 | } 62 | } 63 | } 64 | 65 | &-help { 66 | margin-top: 1.8rem; 67 | width: 100%; 68 | max-width: 440px; 69 | flex-shrink: 0; 70 | margin-left: 3rem; 71 | 72 | .message { 73 | height: 100%; 74 | 75 | code { 76 | font-size: 0.9rem; 77 | } 78 | 79 | ul li { 80 | font-size: 1rem; 81 | 82 | code { 83 | font-size: 0.85rem; 84 | } 85 | } 86 | } 87 | } 88 | 89 | @media @lg-query { 90 | flex-direction: column-reverse; 91 | justify-content: center; 92 | align-items: center; 93 | 94 | &-form { 95 | width: 100%; 96 | } 97 | 98 | &-help { 99 | margin: 0 0 2rem; 100 | max-width: none; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /frontend/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Header, Divider, Search, Icon } from 'semantic-ui-react'; 3 | import { redirectToSignIn } from 'blockstack.js'; 4 | import { Link } from 'react-router-dom'; 5 | import { makeBackendUrl } from '../util/formatters'; 6 | import UserSearch from '../components/UserSearch'; 7 | import BlockstackIcon from '../images/blockstack.svg'; 8 | import './Home.less'; 9 | 10 | function blockstackConnect() { 11 | redirectToSignIn(`${window.location.origin}/auth/blockstack`); 12 | } 13 | 14 | const Home: React.SFC<{}> = () => ( 15 |
16 |
17 |

18 | Show open source some love 19 |

20 |

21 | Set up or contribute to lightning tips for open source projects. 22 | Non-custodial, direct to the creators. 23 |

24 | 25 | 26 | 27 |
28 |
29 |
30 |
31 | Set up your node now 32 |
33 | 41 | 49 | 57 |
58 | or 59 |
60 |
61 | Find someone to tip 62 |
63 | 64 |
65 |
66 |
67 | ); 68 | 69 | export default Home; 70 | -------------------------------------------------------------------------------- /backend/boltathon/models/connection.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from boltathon.extensions import ma, db 3 | 4 | class Connection(db.Model): 5 | __tablename__ = 'connection' 6 | 7 | user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True) 8 | site = db.Column(db.String(63), nullable=False, primary_key=True) 9 | site_id = db.Column(db.String(63), nullable=False) 10 | site_username = db.Column(db.String(63), nullable=False) 11 | date_created = db.Column(db.DateTime) 12 | 13 | def __init__(self, userid: int, site: str, site_id: str, site_username: str): 14 | self.userid = userid 15 | self.site = site 16 | self.site_id = site_id 17 | self.site_username = site_username 18 | self.date_created = datetime.now() 19 | 20 | @staticmethod 21 | def get_user_by_connection(site: str, site_id: str): 22 | connection = Connection.query.filter_by(site=site, site_id=site_id).first() 23 | return connection.user if connection else None 24 | 25 | @staticmethod 26 | def search_usernames(query: str): 27 | return Connection.query.filter(Connection.site_username.ilike('%{}%'.format(query))).all() 28 | 29 | @staticmethod 30 | def search_tippable_users(query: str): 31 | from boltathon.models.user import User 32 | return Connection.query \ 33 | .join(Connection.user) \ 34 | .filter(User.pubkey != None) \ 35 | .filter(Connection.site_username.ilike('%{}%'.format(query))) \ 36 | .all() 37 | 38 | 39 | # Limited data (public view) 40 | class PublicConnectionSchema(ma.Schema): 41 | class Meta: 42 | model = Connection 43 | # Fields to expose 44 | fields = ( 45 | "site", 46 | "site_id", 47 | "site_username", 48 | "user", 49 | ) 50 | 51 | user = ma.Nested("PublicUserSchema", exclude=['connections']) 52 | 53 | public_connection_schema = PublicConnectionSchema() 54 | public_connections_schema = PublicConnectionSchema(many=True) 55 | 56 | # Full data (self view) 57 | class SelfConnectionSchema(ma.Schema): 58 | class Meta: 59 | model = Connection 60 | # Fields to expose 61 | fields = ( 62 | "site", 63 | "site_id", 64 | "site_username", 65 | "date_created", 66 | "user", 67 | ) 68 | 69 | user = ma.Nested("SelfUserSchema", exclude=['connections']) 70 | 71 | self_connection_schema = SelfConnectionSchema() 72 | self_connections_schema = SelfConnectionSchema(many=True) 73 | -------------------------------------------------------------------------------- /backend/migrations/versions/87a598d296cb_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 87a598d296cb 4 | Revises: 5 | Create Date: 2019-04-06 20:14:39.256659 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '87a598d296cb' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('date_created', sa.DateTime(), nullable=True), 24 | sa.Column('email', sa.String(length=255), nullable=True), 25 | sa.Column('macaroon', sa.String(length=2047), nullable=True), 26 | sa.Column('cert', sa.String(length=4095), nullable=True), 27 | sa.Column('node_url', sa.String(length=255), nullable=True), 28 | sa.Column('pubkey', sa.String(length=255), nullable=True), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | op.create_table('connection', 32 | sa.Column('user_id', sa.Integer(), nullable=False), 33 | sa.Column('site', sa.String(length=63), nullable=False), 34 | sa.Column('site_id', sa.String(length=63), nullable=False), 35 | sa.Column('site_username', sa.String(length=63), nullable=False), 36 | sa.Column('date_created', sa.DateTime(), nullable=True), 37 | sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), 38 | sa.PrimaryKeyConstraint('user_id', 'site') 39 | ) 40 | op.create_table('tip', 41 | sa.Column('id', sa.Integer(), nullable=False), 42 | sa.Column('date_created', sa.DateTime(), nullable=True), 43 | sa.Column('receiver_id', sa.Integer(), nullable=False), 44 | sa.Column('sender', sa.String(length=63), nullable=True), 45 | sa.Column('message', sa.String(length=255), nullable=True), 46 | sa.Column('repo', sa.String(length=63), nullable=True), 47 | sa.Column('amount', sa.Integer(), nullable=False), 48 | sa.Column('rhash', sa.String(length=127), nullable=False), 49 | sa.ForeignKeyConstraint(['receiver_id'], ['user.id'], ), 50 | sa.PrimaryKeyConstraint('id', 'receiver_id') 51 | ) 52 | # ### end Alembic commands ### 53 | 54 | 55 | def downgrade(): 56 | # ### commands auto generated by Alembic - please adjust! ### 57 | op.drop_table('tip') 58 | op.drop_table('connection') 59 | op.drop_table('user') 60 | # ### end Alembic commands ### 61 | -------------------------------------------------------------------------------- /backend/boltathon/util/node.py: -------------------------------------------------------------------------------- 1 | import rpc_pb2 as ln 2 | import rpc_pb2_grpc as lnrpc 3 | import grpc 4 | from time import time 5 | from base64 import b64decode 6 | from boltathon.extensions import db 7 | from boltathon.util.lnaddr import lndecode 8 | from boltathon.util.errors import RequestError 9 | 10 | EXPIRY_SECONDS = 300 11 | 12 | def make_invoice(node_url: str, macaroon: str, cert: str): 13 | stub = get_stub(node_url, macaroon, cert) 14 | return stub.AddInvoice(ln.Invoice(expiry=EXPIRY_SECONDS), timeout=10) 15 | 16 | def watch_and_update_tip_invoice(app, tip, invoice): 17 | expiration = time() + EXPIRY_SECONDS 18 | stub = get_stub(tip.recipient.node_url, tip.recipient.macaroon, tip.recipient.cert) 19 | request = ln.InvoiceSubscription(add_index=invoice.add_index) 20 | for inv in stub.SubscribeInvoices(request): 21 | # If the invoice we're watching has expired anyway, break out 22 | if time() > expiration: 23 | break 24 | 25 | # If it's our invoice that's been paid, mark it as such and break out 26 | if inv.r_hash.hex() == invoice.r_hash.hex() and hasattr(inv, 'amt_paid_sat') and inv.amt_paid_sat: 27 | with app.app_context(): 28 | local_tip = db.session.merge(tip) 29 | local_tip.confirm(inv.amt_paid_sat) 30 | db.session.commit() 31 | break 32 | 33 | def get_pubkey_from_credentials(node_url: str, macaroon: str, cert: str): 34 | try: 35 | payment_request = make_invoice(node_url, macaroon, cert).payment_request 36 | decoded = lndecode(payment_request) 37 | if not payment_request or not decoded.pubkey: 38 | raise RequestError(code=400, message='Invalid node credentials') 39 | except: 40 | raise RequestError(code=400, message='Invalid node credentials') 41 | return decoded.pubkey.serialize().hex() 42 | 43 | def lookup_invoice(rhash: str, node_url: str, macaroon: str, cert: str): 44 | stub = get_stub(node_url, macaroon, cert) 45 | request = ln.PaymentHash(r_hash_str=rhash) 46 | return stub.LookupInvoice(request, timeout=10) 47 | 48 | def get_stub(node_url: str, macaroon: str, cert: str): 49 | def metadata_callback(context, callback): 50 | # for more info see grpc docs 51 | callback([('macaroon', macaroon)], None) 52 | 53 | # build ssl credentials using the cert the same as before 54 | cert_creds = grpc.ssl_channel_credentials(b64decode(cert)) 55 | 56 | # now build meta data credentials 57 | auth_creds = grpc.metadata_call_credentials(metadata_callback) 58 | 59 | # combine the cert credentials and the macaroon auth credentials 60 | # such that every call is properly encrypted and authenticated 61 | combined_creds = grpc.composite_channel_credentials(cert_creds, auth_creds) 62 | 63 | # finally pass in the combined credentials when creating a channel 64 | channel = grpc.secure_channel(node_url, combined_creds) 65 | return lnrpc.LightningStub(channel) 66 | -------------------------------------------------------------------------------- /backend/boltathon/app.py: -------------------------------------------------------------------------------- 1 | # Flask 2 | import traceback 3 | from flask import Flask, session, redirect, url_for, request, jsonify 4 | from flask_cors import CORS 5 | from boltathon.extensions import oauth, db, migrate, ma, talisman, compress 6 | from boltathon.util.errors import RequestError 7 | from boltathon import views 8 | # use loginpass to make OAuth connection simpler 9 | 10 | def create_app(config_objects=['boltathon.settings']): 11 | app = Flask( 12 | __name__, 13 | static_folder='static', 14 | static_url_path='/static' 15 | ) 16 | for config in config_objects: 17 | app.config.from_object(config) 18 | 19 | # Extensions 20 | oauth.init_app(app) 21 | db.init_app(app) 22 | migrate.init_app(app, db) 23 | ma.init_app(app) 24 | talisman.init_app( 25 | app, 26 | content_security_policy={ 27 | 'default-src': "'self'", 28 | 'connect-src': ["'self'", 'blockstack.org', '*.blockstack.org'], 29 | 'img-src': ['*', 'data:'], 30 | 'font-src': ['*', 'data:'], 31 | }, 32 | force_https=app.config['ENV'] != 'development', 33 | ) 34 | compress.init_app(app) 35 | 36 | # Enable CORS only in development 37 | if app.config['ENV'] == 'development': 38 | CORS(app, supports_credentials=True) 39 | 40 | # Blueprints 41 | app.register_blueprint(views.api.blueprint) 42 | app.register_blueprint(views.templates.blueprint) 43 | app.register_blueprint(views.oauth.github_blueprint, url_prefix='/oauth/github') 44 | app.register_blueprint(views.oauth.gitlab_blueprint, url_prefix='/oauth/gitlab') 45 | 46 | # Error handling 47 | @app.errorhandler(422) 48 | @app.errorhandler(400) 49 | def handle_error(err): 50 | headers = err.data.get("headers", None) 51 | messages = err.data.get("messages", "Invalid request.") 52 | error_message = "Something was wrong with your request" 53 | if type(messages) == dict: 54 | if 'json' in messages: 55 | error_message = messages['json'][0] 56 | else: 57 | app.logger.warn(f"Unexpected error occurred: {messages}") 58 | if headers: 59 | return jsonify({"error": error_message}), err.code, headers 60 | else: 61 | return jsonify({"error": error_message}), err.code 62 | 63 | @app.errorhandler(404) 64 | def handle_notfound_error(err): 65 | error_message = "Unknown route '{} {}'".format(request.method, request.path) 66 | return jsonify({"error": error_message}), 404 67 | 68 | @app.errorhandler(RequestError) 69 | def handle_request_error(err): 70 | return jsonify({"error": err.message}), err.code 71 | 72 | @app.errorhandler(Exception) 73 | def handle_exception(err): 74 | app.logger.debug(traceback.format_exc()) 75 | app.logger.debug("Uncaught exception at {} {}, see above for traceback".format(request.method, request.path)) 76 | return jsonify({"error": "Something went wrong"}), 500 77 | 78 | return app 79 | -------------------------------------------------------------------------------- /frontend/src/components/TipForm.less: -------------------------------------------------------------------------------- 1 | @circle-size: 120px; 2 | @check-height: @circle-size / 2; 3 | @check-width: @check-height / 2; 4 | @check-left: @circle-size / 4; 5 | @check-thickness: @circle-size / 10; 6 | 7 | @keyframes pop-in { 8 | 0% { 9 | opacity: 0; 10 | transform: scale(0); 11 | } 12 | 100% { 13 | opacity: 1; 14 | transform: scale(1); 15 | } 16 | } 17 | 18 | @keyframes checkmark { 19 | 0% { 20 | height: 0; 21 | width: 0; 22 | transform: scaleX(-1) rotate(135deg); 23 | opacity: 0; 24 | } 25 | 10% { 26 | opacity: 1; 27 | } 28 | 30% { 29 | height: 0; 30 | width: @check-width; 31 | } 32 | 100% { 33 | height: @check-height; width: @check-width; 34 | transform: scaleX(-1) rotate(135deg); 35 | } 36 | } 37 | 38 | .TipForm { 39 | max-width: 380px; 40 | margin: 0 auto; 41 | 42 | &-header { 43 | display: flex; 44 | justify-content: center; 45 | align-items: center; 46 | text-align: center; 47 | font-size: 1.8rem; 48 | margin-bottom: 1.5rem; 49 | 50 | &-image { 51 | width: 2.4rem; 52 | height: 2.4rem; 53 | border-radius: 100%; 54 | margin-right: 1rem; 55 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); 56 | } 57 | } 58 | 59 | &-invoice { 60 | &-qr { 61 | display: flex; 62 | justify-content: center; 63 | align-items: center; 64 | margin: 0 auto 1rem; 65 | } 66 | 67 | &-pr { 68 | margin-bottom: 0.5rem; 69 | 70 | &.form textarea { 71 | font-family: "Lucida Console", Monaco, monospace; 72 | background: rgba(0, 0, 0, 0.05); 73 | color: rgba(0, 0, 0, 0.7); 74 | } 75 | } 76 | } 77 | 78 | &-success { 79 | display: flex; 80 | justify-content: center; 81 | align-items: center; 82 | flex-direction: column; 83 | text-align: center; 84 | 85 | &-text { 86 | font-size: 1.25rem; 87 | line-height: 1.6; 88 | margin-bottom: 2rem; 89 | } 90 | 91 | &-icon { 92 | width: @circle-size; 93 | height: @circle-size; 94 | position: relative; 95 | background: #2EB150; 96 | border-radius: 100%; 97 | animation: pop-in 300ms cubic-bezier(0.450, 0, 0.000, 1) 100ms both; 98 | 99 | &-check:after { 100 | content: ''; 101 | position: absolute; 102 | left: @check-left; 103 | top: @check-height * 1.1; 104 | height: @check-height; 105 | width: @check-width; 106 | border-right: @check-thickness solid white; 107 | border-top: @check-thickness solid white; 108 | border-radius: 2.5px; 109 | opacity: 1; 110 | transform-origin: left top; 111 | transform: scaleX(-1) rotate(135deg); 112 | animation: checkmark 500ms ease 350ms both; 113 | } 114 | } 115 | } 116 | } -------------------------------------------------------------------------------- /backend/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /frontend/src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Icon } from 'semantic-ui-react'; 3 | import CarlImage from '../images/carl.jpg'; 4 | import WillImage from '../images/will.png'; 5 | import './About.less'; 6 | 7 | const About: React.SFC = () => ( 8 |
9 |
10 |
About TipHub
11 |
12 |

13 | TipHub is a non-custodial tipping service, powered by the Lightning 14 | Network. Users can provide the site with credentials to create invoices 15 | that let people tip directly to their Lightning node. 16 |

17 |

18 | Funds are never at risk, as TipHub only asks for enough access to 19 | create and monitor invoices on behalf of the receiver. 20 |

21 |

22 | TipHub currently only supports LND nodes, as that's the only Lightning 23 | node implementation that has standardized auth credentials that allow 24 | partially permissioned access. While we look forward to supporting 25 | more node types, user safety and privacy is our top concern. 26 |

27 |
28 |
29 |
30 |
About Us
31 |
32 |

33 | TipHub was created at the 2019 Bolt-a-Thon hackathon by Will 34 | O'Beirne and Carl Dong. However, anyone is welcome to contribute 35 | at the project GitHub! 36 |

37 |
38 |
39 | 40 |
Will O'Beirne
41 |
42 | Will is an open source engineer focused on the Lightning 43 | Network. He's also the creator of Joule, a lightning extension. 44 |
45 |
46 | 49 | 52 |
53 |
54 | 55 |
56 | 57 |
Carl Dong
58 |
59 | Carl is an engineer at Chaincode Labs, formerly Blockstream. 60 |
61 |
62 | 65 | 68 |
69 |
70 |
71 |
72 |
73 |
74 | ); 75 | 76 | export default About; 77 | -------------------------------------------------------------------------------- /frontend/src/pages/Profile.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Tab, Loader } from 'semantic-ui-react' 3 | import { withRouter, RouteComponentProps } from 'react-router'; 4 | import ProfileHeader from '../components/ProfileHeader'; 5 | import ProfileTips from '../components/ProfileTips'; 6 | import EmbedForm from '../components/EmbedForm'; 7 | import NodeForm from '../components/NodeForm'; 8 | import ConnectionsForm from '../components/ConnectionsForm'; 9 | import api, { User, SelfUser } from '../api'; 10 | import './Profile.less'; 11 | 12 | function isSelfUser(user: User | SelfUser): user is SelfUser { 13 | return !!(user as SelfUser).date_created; 14 | } 15 | 16 | type Props = RouteComponentProps<{ userid: string }>; 17 | 18 | interface State { 19 | user: User | SelfUser | undefined; 20 | error: string; 21 | } 22 | 23 | class Profile extends React.Component { 24 | state: State = { 25 | user: undefined, 26 | error: '', 27 | }; 28 | 29 | componentDidMount() { 30 | const { match, history } = this.props; 31 | const { userid } = match.params; 32 | let req; 33 | if (userid.toLowerCase() === 'me') { 34 | req = api.getSelf(); 35 | } else { 36 | req = api.getUser(parseInt(userid, 10)); 37 | } 38 | 39 | req.then(user => { 40 | if (isSelfUser(user) && !user.pubkey) { 41 | history.replace('/user/setup'); 42 | } 43 | this.setState({ user }); 44 | }).catch(err => { 45 | this.setState({ error: err.message }); 46 | }); 47 | } 48 | 49 | render() { 50 | const { user, error } = this.state; 51 | 52 | if (error) { 53 | return error; 54 | } 55 | 56 | let content; 57 | if (!user || !user.pubkey) { 58 | content = ( 59 |
60 | Loading... 61 |
62 | ); 63 | } 64 | else if (isSelfUser(user)) { 65 | const panes = [{ 66 | menuItem: 'Tips', 67 | render: () => , 68 | }, { 69 | menuItem: 'Embed', 70 | render: () => , 71 | }, { 72 | menuItem: 'Config', 73 | render: () => { 74 | const u = user as SelfUser; 75 | if (!u || !u.macaroon) { 76 | return null; 77 | } 78 | const form = { 79 | node_url: u.node_url, 80 | macaroon: u.macaroon, 81 | cert: u.cert, 82 | email: u.email, 83 | }; 84 | return ( 85 | alert('Saved!')} 89 | /> 90 | ); 91 | }, 92 | }, { 93 | menuItem: 'Connections', 94 | render: () => { 95 | const u = user as SelfUser; 96 | if (!u || !u.macaroon) { 97 | return null; 98 | } 99 | return ; 100 | }, 101 | }]; 102 | content = ( 103 | <> 104 | 105 | 106 | 107 | ); 108 | } else { 109 | content =

Public profile TBD

; 110 | } 111 | 112 | return ( 113 |
114 | {content} 115 |
116 | ); 117 | } 118 | }; 119 | 120 | export default withRouter(Profile); 121 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const path = require('path'); 3 | const webpack = require('webpack'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const DotenvPlugin = require('dotenv-webpack'); 7 | const WebpackPwaManifest = require('webpack-pwa-manifest'); 8 | const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); 9 | const TerserJSPlugin = require('terser-webpack-plugin'); 10 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 11 | 12 | const src = path.join(__dirname, 'src'); 13 | const dist = path.join(__dirname, 'dist'); 14 | 15 | const isDev = process.env.NODE_ENV !== 'production'; 16 | const publicPath = isDev ? '/' : `${process.env.PUBLIC_PATH || ''}/static`; 17 | 18 | const typescriptLoader = { 19 | test: /\.tsx?$/, 20 | use: [ 21 | { 22 | loader: 'ts-loader', 23 | options: { transpileOnly: isDev }, 24 | }, 25 | ], 26 | }; 27 | const cssLoader = { 28 | test: /\.css$/, 29 | use: [ 30 | isDev && 'style-loader', 31 | !isDev && MiniCssExtractPlugin.loader, 32 | { 33 | loader: 'css-loader', 34 | options: { 35 | sourceMap: true, 36 | }, 37 | }, 38 | ].filter(Boolean), 39 | }; 40 | const lessLoader = { 41 | test: /\.less$/, 42 | use: [ 43 | ...cssLoader.use, 44 | { 45 | loader: 'less-loader', 46 | options: { 47 | sourceMap: true, 48 | }, 49 | }, 50 | ], 51 | }; 52 | const fileLoader = { 53 | test: /\.(png|jpg|woff|woff2|eot|ttf|svg)$/, 54 | use: [{ 55 | loader: 'file-loader', 56 | options: { 57 | publicPath, 58 | name: '[folder]/[name].[ext]', 59 | }, 60 | }], 61 | } 62 | 63 | module.exports = { 64 | mode: isDev ? 'development' : 'production', 65 | name: 'main', 66 | target: 'web', 67 | devtool: isDev ? 'cheap-module-inline-source-map' : 'source-map', 68 | entry: path.join(src, 'index.tsx'), 69 | output: { 70 | path: dist, 71 | publicPath, 72 | filename: isDev ? 'script.js' : 'script.[hash:8].js', 73 | chunkFilename: isDev ? '[name].chunk.js' : '[name].[chunkhash:8].chunk.js', 74 | }, 75 | module: { 76 | rules: [ 77 | typescriptLoader, 78 | lessLoader, 79 | cssLoader, 80 | fileLoader, 81 | ].filter(r => !!r), 82 | }, 83 | resolve: { 84 | extensions: ['.ts', '.tsx', '.js', '.mjs', '.json'], 85 | modules: [src, path.join(__dirname, 'node_modules')], 86 | }, 87 | plugins: [ 88 | new MiniCssExtractPlugin({ 89 | filename: isDev ? '[name].css' : '[name].[hash:8].css', 90 | }), 91 | new FaviconsWebpackPlugin({ 92 | logo: path.join(src, 'images/favicon.png'), 93 | inject: true, 94 | }), 95 | new WebpackPwaManifest({ 96 | name: 'TipHub', 97 | fingerprints: false, 98 | description: 'Send sats to your favorite open source contributors!', 99 | background_color: '#333', 100 | crossorigin: 'use-credentials', 101 | icons: [{ 102 | src: path.join(src, 'images/favicon.png'), 103 | sizes: [96, 128, 192, 256, 384, 512], 104 | }], 105 | }), 106 | new HtmlWebpackPlugin({ 107 | template: `${src}/index.html`, 108 | inject: true, 109 | }), 110 | new DotenvPlugin({ systemvars: true }), 111 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), 112 | isDev && new webpack.HotModuleReplacementPlugin(), 113 | ].filter(p => !!p), 114 | optimization: { 115 | minimizer: isDev ? [] : [ 116 | new TerserJSPlugin({ 117 | cache: true, 118 | parallel: true, 119 | sourceMap: true, 120 | }), 121 | new OptimizeCSSAssetsPlugin({ 122 | cssProcessorOptions: { 123 | map: { 124 | inline: false, 125 | annotation: true, 126 | }, 127 | }, 128 | }), 129 | ], 130 | }, 131 | devServer: { 132 | hot: true, 133 | historyApiFallback: true, 134 | headers: { 135 | 'Access-Control-Allow-Origin': '*', 136 | }, 137 | }, 138 | }; 139 | -------------------------------------------------------------------------------- /frontend/src/api.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'query-string'; 2 | import { UserData } from 'blockstack.js/lib/auth/authApp'; 3 | import env from './util/env'; 4 | 5 | export enum ConnectionSite { 6 | github = 'github', 7 | gitlab = 'gitlab', 8 | blockstack = 'blockstack', 9 | } 10 | 11 | export interface Connection { 12 | site: ConnectionSite; 13 | site_id: string; 14 | site_username: string; 15 | user: User; 16 | } 17 | 18 | export interface SelfConnection extends Connection { 19 | date_created: string; 20 | } 21 | 22 | export interface User { 23 | id: number; 24 | pubkey: string; 25 | connections: Connection[]; 26 | } 27 | 28 | export interface SelfUser extends User { 29 | date_created: string; 30 | email: string; 31 | macaroon: string; 32 | cert: string; 33 | node_url: string; 34 | connections: SelfConnection[]; 35 | } 36 | 37 | export interface Tip { 38 | id: number; 39 | date_created: string; 40 | sender: string | null; 41 | message: string | null; 42 | repo: string; 43 | amount: string; 44 | payment_request: string; 45 | } 46 | 47 | export interface PagesData { 48 | page: number; 49 | pages: number; 50 | } 51 | 52 | class API { 53 | url: string; 54 | 55 | constructor(url: string) { 56 | this.url = url; 57 | } 58 | 59 | // Public methods 60 | getSelf() { 61 | return this.request('GET', '/users/me'); 62 | } 63 | 64 | getUser(id: number) { 65 | return this.request('GET', `/users/${id}`); 66 | } 67 | 68 | getUserTips(id: number, page?: number) { 69 | return this.request<{ 70 | user: User; 71 | tips: Tip[]; 72 | pagination: PagesData; 73 | }>('GET', `/users/${id}/tips`, { page }); 74 | } 75 | 76 | updateUser(id: number, args: Partial) { 77 | return this.request('PUT', `/users/${id}`, args); 78 | } 79 | 80 | searchUsers(query: string) { 81 | return this.request('GET', `/users/search/${query}`); 82 | } 83 | 84 | getTip(id: number) { 85 | return this.request('GET', `/tips/${id}`); 86 | } 87 | 88 | makeTip(id: number, args: Partial) { 89 | return this.request('POST', `/users/${id}/tip`, args); 90 | } 91 | 92 | blockstackAuth(data: UserData) { 93 | return this.request('POST', '/auth/blockstack', { 94 | id: data.identityAddress, 95 | username: data.username, 96 | token: data.authResponseToken, 97 | }); 98 | } 99 | 100 | removeConnection(site: ConnectionSite) { 101 | return this.request<{}>('DELETE', `/auth/${site}`); 102 | } 103 | 104 | // Internal fetch function 105 | protected request( 106 | method: 'GET' | 'POST' | 'PUT' | 'DELETE', 107 | path: string, 108 | args?: object, 109 | ): Promise { 110 | let body = null; 111 | let query = ''; 112 | const headers = new Headers(); 113 | headers.append('Accept', 'application/json'); 114 | 115 | if (method === 'POST' || method === 'PUT') { 116 | body = JSON.stringify(args); 117 | headers.append('Content-Type', 'application/json'); 118 | } 119 | else if (args !== undefined) { 120 | // TS Still thinks it might be undefined(?) 121 | query = `?${stringify(args as any)}`; 122 | } 123 | 124 | return fetch(this.url + path + query, { 125 | method, 126 | headers, 127 | body, 128 | credentials: 'include', 129 | }) 130 | .then(async res => { 131 | if (!res.ok) { 132 | let errMsg; 133 | try { 134 | const errBody = await res.json(); 135 | if (!errBody.error) throw new Error(); 136 | errMsg = errBody.error; 137 | } catch(err) { 138 | throw new Error(`${res.status}: ${res.statusText}`); 139 | } 140 | throw new Error(errMsg); 141 | } 142 | return res.json(); 143 | }) 144 | .then(res => res as R) 145 | .catch((err) => { 146 | console.error(`API error calling ${method} ${path}`, err); 147 | throw err; 148 | }); 149 | } 150 | } 151 | 152 | export default new API(`${env.BACKEND_URL}/api`); 153 | -------------------------------------------------------------------------------- /frontend/src/components/EmbedForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form, Dropdown, Icon } from 'semantic-ui-react'; 3 | import CopyToClipboard from 'react-copy-to-clipboard'; 4 | import SyntaxHighlighter from 'react-syntax-highlighter/dist/esm/light'; 5 | import xmlSyntax from 'react-syntax-highlighter/dist/esm/languages/hljs/xml'; 6 | import syntaxStyle from 'react-syntax-highlighter/dist/styles/hljs/xcode'; 7 | import { User, Connection } from '../api'; 8 | import { CONNECTION_UI } from '../util/constants'; 9 | import DarkButton from '../images/tip-button-dark.png'; 10 | import LightButton from '../images/tip-button-light.png'; 11 | import OrangeButton from '../images/tip-button-orange.png'; 12 | import BlueButton from '../images/tip-button-blue.png'; 13 | import './EmbedForm.less'; 14 | 15 | SyntaxHighlighter.registerLanguage('html', xmlSyntax); 16 | 17 | const COLORS = [{ 18 | name: 'Light', 19 | img: LightButton, 20 | }, { 21 | name: 'Dark', 22 | img: DarkButton, 23 | }, { 24 | name: 'Orange', 25 | img: OrangeButton, 26 | }, { 27 | name: 'Blue', 28 | img: BlueButton, 29 | }]; 30 | 31 | interface Props { 32 | user: User; 33 | } 34 | 35 | interface State { 36 | color: typeof COLORS[0]; 37 | connection: Connection; 38 | } 39 | 40 | const makeCode = (id: number, name: string, pubkey: string, img: string, site: string) => 41 | `

42 | 43 | Tip ${name} on TipHub 44 |
45 | My pubkey starts with ${pubkey.slice(0, 8)} 46 |
47 |

`; 48 | 49 | export default class EmbedForm extends React.Component { 50 | state: State = { 51 | color: COLORS[0], 52 | connection: this.props.user.connections[0], 53 | }; 54 | 55 | render() { 56 | const { user } = this.props; 57 | const { color, connection } = this.state; 58 | const code = makeCode(user.id, connection.site_username, user.pubkey, color.img, connection.site); 59 | return ( 60 |
61 |
62 | 63 | 64 | ({ 68 | text: CONNECTION_UI[c.site].name, 69 | value: c.site, 70 | }))} 71 | onChange={this.handleChangeSite} 72 | /> 73 | 74 | 75 | 76 | ({ 80 | text: color.name, 81 | value: color.name, 82 | }))} 83 | onChange={this.handleChangeColor} 84 | /> 85 | 86 | 87 | ev.preventDefault()}> 88 | Copy 89 | 90 | 91 |
92 |
93 |
94 | 95 |
96 |
97 |
98 |
99 |
100 |
101 | ) 102 | } 103 | 104 | private handleChangeColor = (_: any, data: any) => { 105 | const color = COLORS.find(c => c.name === data.value) as typeof COLORS[0]; 106 | this.setState({ color }); 107 | }; 108 | 109 | private handleChangeSite = (_: any, data: any) => { 110 | console.log(data); 111 | const connection = this.props.user.connections.find(c => c.site === data.value) as Connection; 112 | this.setState({ connection }); 113 | }; 114 | } -------------------------------------------------------------------------------- /frontend/src/components/ProfileTips.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import moment from 'moment'; 3 | import { Placeholder, Feed, Pagination, Image, Segment, Header, Icon, PaginationProps } from 'semantic-ui-react'; 4 | import api, { User, Tip, PagesData } from '../api'; 5 | import './ProfileTips.less'; 6 | 7 | interface Props { 8 | user: User; 9 | } 10 | 11 | interface State { 12 | tips: Tip[]; 13 | page: number; 14 | pageData: PagesData | null; 15 | isLoading: boolean; 16 | } 17 | 18 | export default class ProfileTips extends React.Component { 19 | state: State = { 20 | tips: [], 21 | page: 1, 22 | pageData: null, 23 | isLoading: false, 24 | }; 25 | 26 | componentDidMount() { 27 | this.fetchTips(1); 28 | } 29 | 30 | render() { 31 | const { tips, page, pageData, isLoading } = this.state; 32 | 33 | let content; 34 | if (isLoading && !tips.length) { 35 | const placeholder = ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | content = ( 44 | <> 45 | {placeholder} 46 | {placeholder} 47 | {placeholder} 48 | {placeholder} 49 | {placeholder} 50 | 51 | ); 52 | } else { 53 | content = tips.map(t => ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | {moment(t.date_created).fromNow()} 61 | 62 | 63 | {t.sender || Anonymous tipper} 64 | {' '}tipped you {t.amount} sats 65 | 66 | {t.message && ( 67 | "{t.message}" 68 | )} 69 | 70 | 71 | )); 72 | 73 | if (!tips.length) { 74 | content = ( 75 | 76 |
77 | No tips yet 78 |
79 |
80 | ); 81 | } 82 | } 83 | 84 | return ( 85 |
86 | 90 | {content} 91 | 92 | {pageData && pageData.pages > 0 && ( 93 | 98 | )} 99 |
100 | ); 101 | } 102 | 103 | private handleChangePage = (_: any, data: PaginationProps) => { 104 | this.fetchTips(data.activePage as number); 105 | }; 106 | 107 | private fetchTips = async (page: number) => { 108 | this.setState({ page, isLoading: true }); 109 | try { 110 | const res = await api.getUserTips(this.props.user.id, page); 111 | this.setState({ 112 | tips: res.tips, 113 | pageData: res.pagination, 114 | isLoading: false, 115 | }) 116 | } catch(err) { 117 | console.error(err); 118 | alert(err.message); 119 | } 120 | this.setState({ isLoading: false }); 121 | }; 122 | 123 | private getRandomImage = (id: number) => { 124 | const avatars = [ 125 | 'ade', 126 | 'chris', 127 | 'christian', 128 | 'daniel', 129 | 'elliot', 130 | 'helen', 131 | 'jenny', 132 | 'joe', 133 | 'justen', 134 | 'laura', 135 | 'matt', 136 | 'nan', 137 | 'nom', 138 | 'steve', 139 | 'stevie', 140 | 'tom', 141 | 'veronika', 142 | 'zoe', 143 | ]; 144 | const avatar = avatars[id % avatars.length] 145 | return `https://react.semantic-ui.com/images/avatar/small/${avatar}.jpg`; 146 | }; 147 | } -------------------------------------------------------------------------------- /backend/boltathon/util/mail.py: -------------------------------------------------------------------------------- 1 | from sendgrid import SendGridAPIClient 2 | from sendgrid.helpers.mail import Mail 3 | from python_http_client import HTTPError 4 | from boltathon.util import frontend_url 5 | from boltathon.settings import SENDGRID_API_KEY, SENDGRID_DEFAULT_FROM, SENDGRID_DEFAULT_FROMNAME, UI 6 | from flask import render_template, Markup, current_app 7 | 8 | 9 | default_template_args = { 10 | 'home_url': frontend_url('/'), 11 | 'account_url': frontend_url('/user/me'), 12 | 'unsubscribe_url': frontend_url('/user/me?tab=settings') 13 | } 14 | 15 | 16 | def tip_received_info(email_args): 17 | return { 18 | 'subject': 'You just got tipped!', 19 | 'title': 'You got a Tip!', 20 | 'preview': 'Woohoo, somebody just tipped you!', 21 | } 22 | 23 | 24 | def tip_error_info(email_args): 25 | return { 26 | 'subject': 'We had trouble contacting your node for a tip', 27 | 'title': 'Tip Error', 28 | 'preview': 'We recently tried to generate a tip invoice from your node, but encountered an error.' 29 | } 30 | 31 | 32 | get_info_lookup = { 33 | 'tip_received': tip_received_info, 34 | 'tip_error': tip_error_info 35 | } 36 | 37 | 38 | def generate_email(user, type, email_args): 39 | info = get_info_lookup[type](email_args) 40 | body_text = render_template( 41 | 'emails/%s.txt' % (type), 42 | args=email_args, 43 | UI=UI, 44 | ) 45 | body_html = render_template( 46 | 'emails/%s.html' % (type), 47 | args=email_args, 48 | UI=UI, 49 | ) 50 | 51 | template_args = {**default_template_args} 52 | 53 | html = render_template( 54 | 'emails/template.html', 55 | args={ 56 | **template_args, 57 | **info, 58 | 'body': Markup(body_html), 59 | }, 60 | UI=UI, 61 | ) 62 | text = render_template( 63 | 'emails/template.txt', 64 | args={ 65 | **template_args, 66 | **info, 67 | 'body': body_text, 68 | }, 69 | UI=UI, 70 | ) 71 | 72 | return { 73 | 'info': info, 74 | 'html': html, 75 | 'text': text 76 | } 77 | 78 | 79 | def send_email(user, type, email_args): 80 | if current_app and current_app.config.get("TESTING"): 81 | return 82 | 83 | if not user or not user.email: 84 | return 85 | 86 | if not SENDGRID_API_KEY: 87 | current_app.logger.warn('SENDGRID_API_KEY not set, skipping email') 88 | return 89 | 90 | try: 91 | email = generate_email(user, type, email_args) 92 | sg = SendGridAPIClient(SENDGRID_API_KEY) 93 | print(SENDGRID_DEFAULT_FROM) 94 | print(user.email) 95 | mail = Mail( 96 | from_email=SENDGRID_DEFAULT_FROM, 97 | to_emails=user.email, 98 | subject=email['info']['subject'], 99 | plain_text_content=email['text'], 100 | html_content=email['html'], 101 | ) 102 | res = sg.send(mail) 103 | current_app.logger.debug('Just sent an email to %s of type %s, response code: %s' % (user.email, type, res.status_code)) 104 | except HTTPError as e: 105 | current_app.logger.info('An HTTP error occured while sending an email to %s - %s: %s' % (user.email, e.__class__.__name__, e)) 106 | current_app.logger.error(e.body) 107 | except Exception as e: 108 | current_app.logger.info('An unknown error occured while sending an email to %s - %s: %s' % (user.email, e.__class__.__name__, e)) 109 | current_app.logger.error(e) 110 | 111 | # Sends an email once and only once this session of the app. This is pretty 112 | # low-stakes, so it's OK that it rests on restart. Mostly meant to avoid spam. 113 | send_once_map = {} 114 | 115 | def send_email_once(user, type, email_args, extra_key='default'): 116 | if not user or not user.email: 117 | return 118 | 119 | key = '%s - %s - %s' % (user.id, type, extra_key) 120 | 121 | if send_once_map.get(key): 122 | current_app.logger.debug('Already sent with key `%s`' % (key)) 123 | return 124 | 125 | send_once_map[key] = True 126 | return send_email(user, type, email_args) 127 | -------------------------------------------------------------------------------- /backend/boltathon/util/bech32.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2017 Pieter Wuille 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | """Reference implementation for Bech32 and segwit addresses.""" 22 | 23 | 24 | CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" 25 | 26 | 27 | def bech32_polymod(values): 28 | """Internal function that computes the Bech32 checksum.""" 29 | generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] 30 | chk = 1 31 | for value in values: 32 | top = chk >> 25 33 | chk = (chk & 0x1ffffff) << 5 ^ value 34 | for i in range(5): 35 | chk ^= generator[i] if ((top >> i) & 1) else 0 36 | return chk 37 | 38 | 39 | def bech32_hrp_expand(hrp): 40 | """Expand the HRP into values for checksum computation.""" 41 | return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] 42 | 43 | 44 | def bech32_verify_checksum(hrp, data): 45 | """Verify a checksum given HRP and converted data characters.""" 46 | return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1 47 | 48 | 49 | def bech32_create_checksum(hrp, data): 50 | """Compute the checksum values given HRP and data.""" 51 | values = bech32_hrp_expand(hrp) + data 52 | polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1 53 | return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] 54 | 55 | 56 | def bech32_encode(hrp, data): 57 | """Compute a Bech32 string given HRP and data values.""" 58 | combined = data + bech32_create_checksum(hrp, data) 59 | return hrp + '1' + ''.join([CHARSET[d] for d in combined]) 60 | 61 | 62 | def bech32_decode(bech): 63 | """Validate a Bech32 string, and determine HRP and data.""" 64 | if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or 65 | (bech.lower() != bech and bech.upper() != bech)): 66 | return (None, None) 67 | bech = bech.lower() 68 | pos = bech.rfind('1') 69 | if pos < 1 or pos + 7 > len(bech): #or len(bech) > 90: 70 | return (None, None) 71 | if not all(x in CHARSET for x in bech[pos+1:]): 72 | return (None, None) 73 | hrp = bech[:pos] 74 | data = [CHARSET.find(x) for x in bech[pos+1:]] 75 | if not bech32_verify_checksum(hrp, data): 76 | return (None, None) 77 | return (hrp, data[:-6]) 78 | 79 | 80 | def convertbits(data, frombits, tobits, pad=True): 81 | """General power-of-2 base conversion.""" 82 | acc = 0 83 | bits = 0 84 | ret = [] 85 | maxv = (1 << tobits) - 1 86 | max_acc = (1 << (frombits + tobits - 1)) - 1 87 | for value in data: 88 | if value < 0 or (value >> frombits): 89 | return None 90 | acc = ((acc << frombits) | value) & max_acc 91 | bits += frombits 92 | while bits >= tobits: 93 | bits -= tobits 94 | ret.append((acc >> bits) & maxv) 95 | if pad: 96 | if bits: 97 | ret.append((acc << (tobits - bits)) & maxv) 98 | elif bits >= frombits or ((acc << (tobits - bits)) & maxv): 99 | return None 100 | return ret 101 | 102 | 103 | def decode(hrp, addr): 104 | """Decode a segwit address.""" 105 | hrpgot, data = bech32_decode(addr) 106 | if hrpgot != hrp: 107 | return (None, None) 108 | decoded = convertbits(data[1:], 5, 8, False) 109 | if decoded is None or len(decoded) < 2 or len(decoded) > 40: 110 | return (None, None) 111 | if data[0] > 16: 112 | return (None, None) 113 | if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: 114 | return (None, None) 115 | return (data[0], decoded) 116 | 117 | 118 | def encode(hrp, witver, witprog): 119 | """Encode a segwit address.""" 120 | ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5)) 121 | assert decode(hrp, ret) is not (None, None) 122 | return ret 123 | 124 | -------------------------------------------------------------------------------- /frontend/src/components/ConnectionsForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Icon, Message, Modal } from 'semantic-ui-react'; 3 | import { redirectToSignIn } from 'blockstack.js'; 4 | import api, { SelfUser, ConnectionSite, SelfConnection } from '../api'; 5 | import { makeBackendUrl, makeConnectionUrl } from '../util/formatters'; 6 | import { CONNECTION_UI } from '../util/constants'; 7 | import BlockstackIcon from '../images/blockstack.svg'; 8 | import './ConnectionsForm.less'; 9 | 10 | interface Props { 11 | user: SelfUser; 12 | } 13 | 14 | interface State { 15 | siteToRemove: null | ConnectionSite; 16 | error: null | string; 17 | } 18 | 19 | export default class ConnectionsForm extends React.Component { 20 | state: State = { 21 | siteToRemove: null, 22 | error: null, 23 | }; 24 | 25 | render() { 26 | const { user } = this.props; 27 | const { error, siteToRemove } = this.state; 28 | const cmap = user.connections.reduce((prev, c) => { 29 | prev[c.site] = c; 30 | return prev; 31 | }, {} as { [key in ConnectionSite]: SelfConnection }); 32 | const buttons = [{ 33 | site: ConnectionSite.github, 34 | text: 'Connect GitHub account', 35 | color: 'black', 36 | icon: , 37 | }, { 38 | site: ConnectionSite.gitlab, 39 | text: 'Connect GitLab account', 40 | color: 'orange', 41 | icon: , 42 | }, { 43 | site: ConnectionSite.blockstack, 44 | text: 'Connect Blockstack identity', 45 | color: 'purple', 46 | icon: , 47 | onClick: cmap[ConnectionSite.blockstack] ? undefined : this.blockstackConnect, 48 | }]; 49 | 50 | return ( 51 |
52 | {error && 53 | 54 | Something went wrong 55 |

{error}

56 |
57 | } 58 | 59 | {buttons.map(b => { 60 | const c = cmap[b.site]; 61 | let onClick = b.onClick; 62 | let href, target; 63 | 64 | if (c) { 65 | href = makeConnectionUrl(c); 66 | target = '_blank'; 67 | } else if (onClick) { 68 | onClick = b.onClick; 69 | } else { 70 | href = makeBackendUrl(`/oauth/${b.site}/login`); 71 | } 72 | 73 | return ( 74 |
75 | 86 | 96 |
97 | ); 98 | })} 99 | 100 |

101 | Removing all connections will delete your account 102 |

103 | 104 | 105 | Remove connection 106 | 107 | {siteToRemove && ( 108 | <> 109 |

110 | Are you sure you want to remove your connection to{' '} 111 | {CONNECTION_UI[siteToRemove].name}? 112 | You will not be able to log in using this connection anymore, 113 | and users will no longer be able to search for you with it. 114 |

115 | {user.connections.length === 1 ? ( 116 | 117 | 118 | You are removing your last connection 119 | 120 |

121 | This will delete your account. You will lose all history 122 | of your tips, and all of your tip buttons will stop 123 | functioning. You can always make a new account, but 124 | your old tip buttons will not update with the new account. 125 |

126 |

127 | This is irreversible. 128 | {' '}Are you sure you want to continue? 129 |

130 |
131 | ) : ( 132 |

133 | You can always add the connection back later. 134 |

135 | )} 136 | 137 | )} 138 |
139 | 140 |
151 | ) 152 | } 153 | 154 | private blockstackConnect() { 155 | redirectToSignIn(`${window.location.origin}/auth/blockstack`); 156 | } 157 | 158 | private openRemoveModal = (site: ConnectionSite) => { 159 | this.setState({ siteToRemove: site }); 160 | }; 161 | 162 | private closeRemoveModal = () => { 163 | this.setState({ siteToRemove: null }); 164 | }; 165 | 166 | private removeConnection = () => { 167 | const { siteToRemove } = this.state; 168 | if (!siteToRemove) return; 169 | 170 | this.setState({ error: null }); 171 | api.removeConnection(siteToRemove).then(() => { 172 | // TODO: Update state & user object instead! 173 | window.location.reload(); 174 | }).catch(err => { 175 | this.setState({ 176 | siteToRemove: null, 177 | error: err.message, 178 | }); 179 | }); 180 | }; 181 | } -------------------------------------------------------------------------------- /frontend/src/components/TipForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Form, Segment, Button, TextArea, Divider, Message } from 'semantic-ui-react'; 3 | import { Link } from 'react-router-dom'; 4 | import { withRouter, RouteComponentProps } from 'react-router'; 5 | import { parse } from 'query-string'; 6 | import QRCode from 'qrcode.react'; 7 | import api, { Tip, User } from '../api'; 8 | import { CONNECTION_UI } from '../util/constants'; 9 | import './TipForm.less'; 10 | 11 | 12 | interface OwnProps { 13 | user: User; 14 | }; 15 | 16 | type Props = RouteComponentProps & OwnProps; 17 | 18 | interface State { 19 | sender: string; 20 | message: string; 21 | tip: Tip | null; 22 | error: string; 23 | isSubmitting: boolean; 24 | } 25 | 26 | class TipForm extends React.Component { 27 | state: State = { 28 | sender: '', 29 | message: '', 30 | tip: null, 31 | error: '', 32 | isSubmitting: false, 33 | }; 34 | 35 | componentDidMount() { 36 | this.pollTip(); 37 | } 38 | 39 | render() { 40 | const { user, location } = this.props; 41 | const { sender, message, tip, isSubmitting, error } = this.state; 42 | const { site } = parse(location.search); 43 | const connection = user.connections.find(c => c.site === site) || user.connections[0]; 44 | 45 | let content; 46 | if (tip) { 47 | if (tip.amount) { 48 | content = ( 49 |
50 |
51 |
52 |
53 |

54 | Payment successful! 55 |

56 |

57 | You just supported open source development. 58 | {this.getRandomPraise()} 59 |

60 |
61 | 62 | 65 | 66 | 69 |
70 |
71 | ); 72 | } else { 73 | const url = `lightning:${tip.payment_request}`; 74 | content = ( 75 |
76 | 77 | 78 | 79 |
80 |