├── 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 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | '404'} />
23 |
24 |
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 |
20 |
30 |
31 |
32 |
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 |
19 |
29 |
30 |
31 |
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 |
22 |
23 |
24 |
25 | About
26 | Account
27 |
28 |
29 |
30 |
31 |
32 | {children}
33 |
34 |
35 |
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 |
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 |
36 | {c.site_username}
37 |
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 |
Learn more
26 |
27 |
28 |
29 |
30 |
31 | Set up your node now
32 |
33 |
39 | Connect with GitHub
40 |
41 |
47 | Connect with GitLab
48 |
49 |
55 | {' '}Connect with Blockstack
56 |
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 |
47 | wbobeirne
48 |
49 |
50 | wbobeirne
51 |
52 |
53 |
54 |
55 |
56 |
57 |
Carl Dong
58 |
59 | Carl is an engineer at Chaincode Labs, formerly Blockstream.
60 |
61 |
62 |
63 | carl_dong
64 |
65 |
66 | dongcarl
67 |
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 |
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 |
63 | Site
64 | ({
68 | text: CONNECTION_UI[c.site].name,
69 | value: c.site,
70 | }))}
71 | onChange={this.handleChangeSite}
72 | />
73 |
74 |
75 | Color
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 |
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 |
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 |
84 | {b.icon} {c ? c.site_username : b.text}
85 |
86 | this.openRemoveModal(c.site) : undefined}
93 | >
94 |
95 |
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 |
141 |
148 |
149 |
150 |
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 |
53 |
54 | Payment successful!
55 |
56 |
57 | You just supported open source development.
58 | {this.getRandomPraise()}
59 |
60 |
61 |
62 |
63 | Go Home
64 |
65 |
66 | window.close()}>
67 | Close Page
68 |
69 |
70 |
71 | );
72 | } else {
73 | const url = `lightning:${tip.payment_request}`;
74 | content = (
75 |
76 |
77 |
78 |
79 |
82 |
83 | ⚡ Open in Wallet
84 |
85 |
86 | );
87 | }
88 | } else {
89 | content = (
90 | <>
91 |
92 |
100 |
108 |
109 | Start tipping
110 |
111 |
112 | or
113 |
114 | Tip anonymously
115 |
116 | >
117 | );
118 | }
119 |
120 | return (
121 |
122 |
123 |
127 | You’re tipping {connection.site_username}
128 |
129 | {error && (
130 |
{error}
131 | )}
132 |
133 | {content}
134 |
135 |
136 | );
137 | }
138 |
139 | private handleChange = (ev: React.ChangeEvent) => {
140 | this.setState({ [ev.target.name]: ev.target.value } as any);
141 | };
142 |
143 | private submitAnonymously = () => {
144 | this.setState({ sender: '', message: '', }, () => {
145 | this.handleSubmit();
146 | });
147 | };
148 |
149 | private handleSubmit = async (ev?: React.FormEvent) => {
150 | if (ev) ev.preventDefault();
151 | const { sender, message } = this.state;
152 | this.setState({
153 | error: '',
154 | isSubmitting: true,
155 | })
156 | try {
157 | const tip = await api.makeTip(this.props.user.id, { sender, message });
158 | this.setState({ tip });
159 | } catch(err) {
160 | this.setState({ error: err.message });
161 | }
162 | this.setState({ isSubmitting: false });
163 | };
164 |
165 | private pollTip = async () => {
166 | const { tip } = this.state;
167 | if (tip) {
168 | // All done here
169 | if (tip.amount) {
170 | return;
171 | }
172 | // Fetch the latest version
173 | try {
174 | let newTip = await api.getTip(tip.id);
175 | this.setState({ tip: newTip });
176 | } catch(err) {
177 | this.setState({ error: err.message });
178 | }
179 | }
180 | setTimeout(this.pollTip, 3000);
181 | }
182 |
183 | private getRandomPraise = () => {
184 | const { tip } = this.state;
185 | const praises = [
186 | 'Go ahead and pat yourself on the back. I won’t judge.',
187 | 'You are the bee’s knees, the cat’s pajamas, the monkey’s eyebrows.',
188 | 'A starving developer won’t go hungry tonight, thanks to you.',
189 | 'Good luck trying to write off this donation on your taxes.',
190 | 'You’ve given us that warm, fuzzy feeling.',
191 | 'Who’s next?!',
192 | 'You have been blessed by Satoshi for your kindness.',
193 | 'That’s your good deed for the day.',
194 | ];
195 | return ' ' + praises[(tip ? tip.id : 0) % praises.length];
196 | };
197 | }
198 |
199 | export default withRouter(TipForm);
200 |
--------------------------------------------------------------------------------
/frontend/src/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Artboard
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/backend/boltathon/views/api.py:
--------------------------------------------------------------------------------
1 | import threading
2 | from flask import Blueprint, g, jsonify, session, current_app
3 | from webargs import fields, validate
4 | from webargs.flaskparser import use_args
5 | from grpc import RpcError
6 | from boltathon.extensions import db
7 | from boltathon.util import frontend_url
8 | from boltathon.util.auth import requires_auth, get_authed_user
9 | from boltathon.util.node import (
10 | get_pubkey_from_credentials,
11 | make_invoice,
12 | lookup_invoice,
13 | watch_and_update_tip_invoice,
14 | )
15 | from boltathon.util.errors import RequestError
16 | from boltathon.util.mail import send_email_once
17 | from boltathon.util.blockstack import validate_blockstack_auth
18 | from boltathon.models.user import User, self_user_schema, public_user_schema, public_users_schema
19 | from boltathon.models.connection import Connection, public_connections_schema
20 | from boltathon.models.tip import Tip, tip_schema, tips_schema
21 |
22 | blueprint = Blueprint("api", __name__, url_prefix="/api")
23 |
24 |
25 | @blueprint.route('/users/me', methods=['GET'])
26 | @requires_auth
27 | def get_self_user():
28 | return jsonify(self_user_schema.dump(g.current_user))
29 |
30 |
31 |
32 | @blueprint.route('/users//tips', methods=['GET'])
33 | @use_args({
34 | 'user_id': fields.Integer(location='view_args', required=True),
35 | 'page': fields.Integer(required=False, missing=0, validate=validate.Range(0)),
36 | 'limit': fields.Integer(required=False, missing=10, validate=validate.Range(1, 10)),
37 | })
38 | def get_user_tips(args, **kwargs):
39 | user = User.query.get(args['user_id'])
40 | if not user:
41 | raise RequestError(code=404, message='No user with that ID')
42 |
43 | tips = Tip.query \
44 | .filter_by(receiver_id=user.id) \
45 | .filter(Tip.amount != None ) \
46 | .filter(Tip.amount != 0) \
47 | .order_by(Tip.date_created.desc()) \
48 | .paginate(
49 | page=args['page'],
50 | per_page=args['limit'],
51 | error_out=False)
52 | return jsonify({
53 | 'user': public_user_schema.dump(user),
54 | 'tips': tips_schema.dump(tips.items),
55 | 'pagination': {
56 | 'page': tips.page,
57 | 'pages': tips.pages,
58 | },
59 | })
60 |
61 |
62 | @blueprint.route('/users/', methods=['GET'])
63 | def get_user(user_id):
64 | user = User.query.get(user_id)
65 | if not user:
66 | raise RequestError(code=404, message='No user with that ID')
67 | return jsonify(public_user_schema.dump(user))
68 |
69 |
70 | @blueprint.route('/users/', methods=['PUT'])
71 | @requires_auth
72 | @use_args({
73 | 'user_id': fields.Integer(location='view_args', required=True),
74 | 'node_url': fields.Str(required=False, missing=None),
75 | 'macaroon': fields.Str(required=False, missing=None),
76 | 'cert': fields.Str(required=False, missing=None),
77 | 'email': fields.Str(required=False, missing=None)
78 | })
79 | def update_user(args, **kwargs):
80 | for key in args:
81 | if args.get(key) != None:
82 | setattr(g.current_user, key, args[key])
83 |
84 | print(g.current_user.node_url)
85 | print(g.current_user.macaroon)
86 | print(g.current_user.cert)
87 | pubkey = get_pubkey_from_credentials(
88 | g.current_user.node_url,
89 | g.current_user.macaroon,
90 | g.current_user.cert,
91 | )
92 | g.current_user.pubkey = pubkey
93 |
94 | db.session.add(g.current_user)
95 | db.session.commit()
96 |
97 | return jsonify(self_user_schema.dump(g.current_user))
98 |
99 |
100 |
101 | @blueprint.route('/users//tip', methods=['POST'])
102 | @use_args({
103 | 'user_id': fields.Integer(location='view_args', required=True),
104 | 'sender': fields.Str(required=False, missing=None),
105 | 'message': fields.Str(required=False, missing=None),
106 | })
107 | def post_invoice(args, user_id, **kwargs):
108 | user = User.query.get(user_id)
109 | if not user:
110 | raise RequestError(code=404, message='No user with that ID')
111 |
112 | err = None
113 |
114 | try:
115 | # Create invoice & tip
116 | invoice = make_invoice(user.node_url, user.macaroon, user.cert)
117 | tip = Tip(
118 | receiver_id=args.get('user_id'),
119 | sender=args.get('sender'),
120 | message=args.get('message'),
121 | payment_request=invoice.payment_request,
122 | rhash=invoice.r_hash.hex(),
123 | )
124 | db.session.add(tip)
125 | db.session.commit()
126 |
127 | # Start thread to watch tip
128 | t = threading.Thread(
129 | target=watch_and_update_tip_invoice,
130 | args=(current_app._get_current_object(), tip, invoice)
131 | )
132 | t.start()
133 |
134 | return jsonify(tip_schema.dump(tip))
135 | except RequestError as e:
136 | err = 'Request error: {}'.format(e.message)
137 | except RpcError as e:
138 | err = 'RPC error: {}'.format(e.details())
139 | except Exception as e:
140 | err = 'Unknown exception: {}'.format(str(e))
141 | current_app.logger.info('Unknown error encountered while trying to generate an invoice for user {}'.format(user.id))
142 | current_app.logger.error(e)
143 |
144 | if err:
145 | send_email_once(user, 'tip_error', {
146 | 'error': err,
147 | 'config_url': frontend_url('/user/me?tab=config'),
148 | 'support_url': 'https://github.com/tiphub-io/tiphub/issues',
149 | }, err)
150 | raise RequestError(code=500, message='Failed to generate a tip invoice, their node may be offline or otherwise inaccessible')
151 |
152 |
153 | @blueprint.route('/users/search/', methods=['GET'])
154 | def search_users(query):
155 | connections = Connection.search_tippable_users(query)
156 | return jsonify(public_connections_schema.dump(connections[:5]))
157 |
158 |
159 | @blueprint.route('/tips/', methods=['GET'])
160 | def get_tip(tip_id):
161 | tip = Tip.query.get(tip_id)
162 | if not tip:
163 | raise RequestError(code=404, message='No tip with that ID')
164 | return jsonify(tip_schema.dump(tip))
165 |
166 |
167 | @blueprint.route('/auth/blockstack', methods=['POST'])
168 | @use_args({
169 | 'id': fields.Str(required=True),
170 | 'username': fields.Str(required=True),
171 | 'token': fields.Str(required=True),
172 | })
173 | def blockstack_auth(args):
174 | # Assert that they generated a valid token
175 | if not validate_blockstack_auth(args.get('id'), args.get('username'), args.get('token')):
176 | raise RequestError(code=400, message='Invalid Blockstack token provided')
177 |
178 | # Find or create a new user and add the connection
179 | user = Connection.get_user_by_connection(
180 | site='blockstack',
181 | site_id=args.get('id'),
182 | )
183 | if not user:
184 | user = get_authed_user()
185 | if not user:
186 | user = User()
187 | connection = Connection(
188 | userid=user.id,
189 | site='blockstack',
190 | site_id=args.get('id'),
191 | site_username=args.get('username'),
192 | )
193 | user.connections.append(connection)
194 | db.session.add(user)
195 | db.session.add(connection)
196 | db.session.commit()
197 |
198 | # Log them in if they weren't before
199 | session['user_id'] = user.id
200 |
201 | return jsonify(self_user_schema.dump(user))
202 |
203 |
204 | @blueprint.route('/auth/', methods=['DELETE'])
205 | @requires_auth
206 | def delete_auth(site):
207 | conn = [c for c in g.current_user.connections if c.site == site]
208 |
209 | if not conn:
210 | raise RequestError(code=400, message="You do not have a connection with {}".format(site))
211 |
212 | # Delete whole account if last connection, or just connection otherwise
213 | if len(g.current_user.connections) == 1:
214 | db.session.delete(g.current_user)
215 | else:
216 | db.session.delete(conn[0])
217 |
218 | db.session.commit()
219 | return jsonify({ "success": True })
220 |
--------------------------------------------------------------------------------
/backend/boltathon/templates/emails/template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
73 |
74 |
75 |
76 |
77 |
78 | {{ args.preview }}
79 |
80 |
81 |
82 |
83 |
84 |
85 |
90 |
101 |
106 |
107 |
108 |
109 |
110 |
111 |
116 |
117 |
118 |
120 |
121 | {{ args.title }}
122 |
123 |
124 |
125 |
126 |
131 |
132 |
133 |
134 |
135 |
136 |
141 |
142 |
143 |
145 | {{ args.body }}
146 |
147 |
148 |
149 |
154 |
155 |
156 |
157 |
158 |
159 |
164 |
195 |
200 |
201 |
202 |
203 |
204 |
205 |
--------------------------------------------------------------------------------
/frontend/src/components/NodeForm.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Form, Input, Message, Button, Grid } from 'semantic-ui-react';
3 | import { blobToString } from '../util/formatters';
4 | import api, { SelfUser } from '../api';
5 | import './NodeForm.less';
6 |
7 | interface FormState {
8 | node_url: string;
9 | macaroon: string;
10 | cert: string;
11 | email: string;
12 | }
13 |
14 | const defaultHelpText = {
15 | title: 'Need help?',
16 | content: (
17 | <>
18 |
19 | We need some connection information to handle payments for your
20 | node.
21 |
22 |
23 | If you're confused about any of the fields, just hit the help
24 | button beside it.
25 |
26 |
27 | There's no security risk to your node by providing us this
28 | information. We'll only be able to generate new invoices, we
29 | won't have access to your funds.
30 |
31 | >
32 | ),
33 | };
34 | const helpText = {
35 | node_url: {
36 | title: 'gRPC Endpoint',
37 | content: (
38 | <>
39 |
40 | The gRPC endpoint is the URL we'll use to communicate with your node.
41 | It is configured by rpclisten in lnd.conf.
42 |
43 |
44 | Every time someone wants to tip you, we'll make a request to it to
45 | generate a new invoice. This means your server must be online and
46 | accessible at all times.
47 |
48 | >
49 | ),
50 | },
51 | macaroon: {
52 | title: 'Invoice Macaroon',
53 | content: (
54 | <>
55 |
56 | The invoice macaroon is a file that gives us authorized access
57 | to generate invoices on your node.
58 |
59 |
60 | Make sure you do not upload your readonly.macaroon or
61 | {' '}admin.macaroon files accidentally.
62 |
63 |
64 | The macaroons are usually located:
65 |
66 |
69 | macOS: {' '}
70 | ~/Library/Application Support/Lnd/data/chain/*
71 | >
72 | )},
73 | { content: (
74 | <>
75 | Linux: {' '}
76 | ~/.lnd/data/chain/*
77 | >
78 | )},
79 | { content: (
80 | <>
81 | Window: {' '}
82 | %APPDATA%\Lnd\data\chain\*
83 | >
84 | )},
85 | ]} />
86 | >
87 | ),
88 | },
89 | cert: {
90 | title: 'TLS Certificate',
91 | content: (
92 | <>
93 |
94 | The TLS certificate ensures our server that we're talking to your
95 | node, and encrypts the communication.
96 |
97 |
98 | The certificate is usually located:
99 |
100 |
103 | macOS: {' '}
104 | ~/Library/Application Support/Lnd
105 | >
106 | )},
107 | { content: (
108 | <>
109 | Linux: {' '}
110 | ~/.lnd
111 | >
112 | )},
113 | { content: (
114 | <>
115 | Window: {' '}
116 | %APPDATA%\Lnd
117 | >
118 | )},
119 | ]} />
120 | >
121 | ),
122 | },
123 | };
124 | type HelpKey = keyof typeof helpText;
125 |
126 | interface Props {
127 | userid: number;
128 | initialFormState?: FormState;
129 | onSubmit(user: SelfUser): void;
130 | }
131 |
132 | interface State {
133 | form: FormState;
134 | uploaded: {
135 | macaroon: boolean;
136 | cert: boolean;
137 | };
138 | helpKey: HelpKey | null;
139 | error: string;
140 | isSubmitting: boolean;
141 | }
142 |
143 | export default class NodeForm extends React.Component {
144 | state: State = {
145 | form: {
146 | node_url: '',
147 | macaroon: '',
148 | cert: '',
149 | email: '',
150 | },
151 | uploaded: {
152 | macaroon: false,
153 | cert: false,
154 | },
155 | helpKey: null,
156 | error: '',
157 | isSubmitting: false,
158 | };
159 |
160 | constructor(props: Props) {
161 | super(props);
162 | if (props.initialFormState) {
163 | this.state = {
164 | ...this.state,
165 | form: { ...props.initialFormState },
166 | };
167 | }
168 | }
169 |
170 | render() {
171 | const { form, uploaded, helpKey, error, isSubmitting } = this.state;
172 | const size = 'large';
173 | const help = helpText[helpKey as HelpKey] || defaultHelpText;
174 | return (
175 |
253 | );
254 | }
255 |
256 | private renderLabel = (label: string, helpKey: HelpKey) => {
257 | return (
258 |
259 | {label}
260 | {
265 | ev.preventDefault();
266 | this.setState({ helpKey });
267 | }}
268 | />
269 |
270 | );
271 | };
272 |
273 | private handleChange = (ev: React.ChangeEvent) => {
274 | const form: FormState = {
275 | ...this.state.form,
276 | [ev.target.name]: ev.target.value,
277 | };
278 | this.setState({ form });
279 | };
280 |
281 | private handleMacaroon = (ev: React.ChangeEvent) => {
282 | this.handleFileOrInput('macaroon', ev.target);
283 | };
284 |
285 | private handleCert = (ev: React.ChangeEvent) => {
286 | this.handleFileOrInput('cert', ev.target);
287 | };
288 |
289 | private handleFileOrInput = async (name: 'cert' | 'macaroon', target: HTMLInputElement) => {
290 | if (!target.files) return;
291 | const file = target.files[0];
292 | let form, uploaded;
293 | if (file) {
294 | const value = await blobToString(file, name === 'cert' ? 'base64' : 'hex');
295 | form = {
296 | ...this.state.form,
297 | [name]: value,
298 | };
299 | uploaded = {
300 | ...this.state.uploaded,
301 | [name]: true,
302 | };
303 | } else {
304 | form = {
305 | ...this.state.form,
306 | [name]: '',
307 | };
308 | uploaded = {
309 | ...this.state.uploaded,
310 | [name]: true,
311 | };
312 | }
313 | this.setState({
314 | form,
315 | uploaded,
316 | });
317 | };
318 |
319 | private handleSubmit = async (ev: React.FormEvent) => {
320 | ev.preventDefault();
321 | this.setState({
322 | error: '',
323 | isSubmitting: true,
324 | })
325 | try {
326 | const user = await api.updateUser(this.props.userid, this.state.form);
327 | this.props.onSubmit(user);
328 | } catch(err) {
329 | this.setState({ error: err.message });
330 | }
331 | this.setState({ isSubmitting: false });
332 | };
333 | }
334 |
--------------------------------------------------------------------------------
/backend/boltathon/util/lnaddr.py:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env python3
2 | from boltathon.util.bech32 import bech32_encode, bech32_decode, CHARSET
3 | from binascii import hexlify, unhexlify
4 | from bitstring import BitArray
5 | from decimal import Decimal
6 |
7 | import base58
8 | import bitstring
9 | import hashlib
10 | import math
11 | import re
12 | import secp256k1
13 | import sys
14 | import time
15 |
16 |
17 | # BOLT #11:
18 | #
19 | # A writer MUST encode `amount` as a positive decimal integer with no
20 | # leading zeroes, SHOULD use the shortest representation possible.
21 | def shorten_amount(amount):
22 | """ Given an amount in bitcoin, shorten it
23 | """
24 | # Convert to pico initially
25 | amount = int(amount * 10**12)
26 | units = ['p', 'n', 'u', 'm', '']
27 | for unit in units:
28 | if amount % 1000 == 0:
29 | amount //= 1000
30 | else:
31 | break
32 | return str(amount) + unit
33 |
34 | def unshorten_amount(amount):
35 | """ Given a shortened amount, convert it into a decimal
36 | """
37 | # BOLT #11:
38 | # The following `multiplier` letters are defined:
39 | #
40 | #* `m` (milli): multiply by 0.001
41 | #* `u` (micro): multiply by 0.000001
42 | #* `n` (nano): multiply by 0.000000001
43 | #* `p` (pico): multiply by 0.000000000001
44 | units = {
45 | 'p': 10**12,
46 | 'n': 10**9,
47 | 'u': 10**6,
48 | 'm': 10**3,
49 | }
50 | unit = str(amount)[-1]
51 | # BOLT #11:
52 | # A reader SHOULD fail if `amount` contains a non-digit, or is followed by
53 | # anything except a `multiplier` in the table above.
54 | if not re.fullmatch("\d+[pnum]?", str(amount)):
55 | raise ValueError("Invalid amount '{}'".format(amount))
56 |
57 | if unit in units.keys():
58 | return Decimal(amount[:-1]) / units[unit]
59 | else:
60 | return Decimal(amount)
61 |
62 | # Bech32 spits out array of 5-bit values. Shim here.
63 | def u5_to_bitarray(arr):
64 | ret = bitstring.BitArray()
65 | for a in arr:
66 | ret += bitstring.pack("uint:5", a)
67 | return ret
68 |
69 | def bitarray_to_u5(barr):
70 | assert barr.len % 5 == 0
71 | ret = []
72 | s = bitstring.ConstBitStream(barr)
73 | while s.pos != s.len:
74 | ret.append(s.read(5).uint)
75 | return ret
76 |
77 | def encode_fallback(fallback, currency):
78 | """ Encode all supported fallback addresses.
79 | """
80 | if currency == 'bc' or currency == 'tb':
81 | fbhrp, witness = bech32_decode(fallback)
82 | if fbhrp:
83 | if fbhrp != currency:
84 | raise ValueError("Not a bech32 address for this currency")
85 | wver = witness[0]
86 | if wver > 16:
87 | raise ValueError("Invalid witness version {}".format(witness[0]))
88 | wprog = u5_to_bitarray(witness[1:])
89 | else:
90 | addr = base58.b58decode_check(fallback)
91 | if is_p2pkh(currency, addr[0]):
92 | wver = 17
93 | elif is_p2sh(currency, addr[0]):
94 | wver = 18
95 | else:
96 | raise ValueError("Unknown address type for {}".format(currency))
97 | wprog = addr[1:]
98 | return tagged('f', bitstring.pack("uint:5", wver) + wprog)
99 | else:
100 | raise NotImplementedError("Support for currency {} not implemented".format(currency))
101 |
102 | def parse_fallback(fallback, currency):
103 | if currency == 'bc' or currency == 'tb':
104 | wver = fallback[0:5].uint
105 | if wver == 17:
106 | addr=base58.b58encode_check(bytes([base58_prefix_map[currency][0]])
107 | + fallback[5:].tobytes())
108 | elif wver == 18:
109 | addr=base58.b58encode_check(bytes([base58_prefix_map[currency][1]])
110 | + fallback[5:].tobytes())
111 | elif wver <= 16:
112 | addr=bech32_encode(currency, bitarray_to_u5(fallback))
113 | else:
114 | return None
115 | else:
116 | addr=fallback.tobytes()
117 | return addr
118 |
119 |
120 | # Map of classical and witness address prefixes
121 | base58_prefix_map = {
122 | 'bc' : (0, 5),
123 | 'tb' : (111, 196)
124 | }
125 |
126 | def is_p2pkh(currency, prefix):
127 | return prefix == base58_prefix_map[currency][0]
128 |
129 | def is_p2sh(currency, prefix):
130 | return prefix == base58_prefix_map[currency][1]
131 |
132 | # Tagged field containing BitArray
133 | def tagged(char, l):
134 | # Tagged fields need to be zero-padded to 5 bits.
135 | while l.len % 5 != 0:
136 | l.append('0b0')
137 | return bitstring.pack("uint:5, uint:5, uint:5",
138 | CHARSET.find(char),
139 | (l.len / 5) / 32, (l.len / 5) % 32) + l
140 |
141 | # Tagged field containing bytes
142 | def tagged_bytes(char, l):
143 | return tagged(char, bitstring.BitArray(l))
144 |
145 | # Discard trailing bits, convert to bytes.
146 | def trim_to_bytes(barr):
147 | # Adds a byte if necessary.
148 | b = barr.tobytes()
149 | if barr.len % 8 != 0:
150 | return b[:-1]
151 | return b
152 |
153 | # Try to pull out tagged data: returns tag, tagged data and remainder.
154 | def pull_tagged(stream):
155 | tag = stream.read(5).uint
156 | length = stream.read(5).uint * 32 + stream.read(5).uint
157 | return (CHARSET[tag], stream.read(length * 5), stream)
158 |
159 | def lnencode(addr, privkey):
160 | if addr.amount:
161 | amount = Decimal(str(addr.amount))
162 | # We can only send down to millisatoshi.
163 | if amount * 10**12 % 10:
164 | raise ValueError("Cannot encode {}: too many decimal places".format(
165 | addr.amount))
166 |
167 | amount = addr.currency + shorten_amount(amount)
168 | else:
169 | amount = addr.currency if addr.currency else ''
170 |
171 | hrp = 'ln' + amount
172 |
173 | # Start with the timestamp
174 | data = bitstring.pack('uint:35', addr.date)
175 |
176 | # Payment hash
177 | data += tagged_bytes('p', addr.paymenthash)
178 | tags_set = set()
179 |
180 | for k, v in addr.tags:
181 |
182 | # BOLT #11:
183 | #
184 | # A writer MUST NOT include more than one `d`, `h`, `n` or `x` fields,
185 | if k in ('d', 'h', 'n', 'x'):
186 | if k in tags_set:
187 | raise ValueError("Duplicate '{}' tag".format(k))
188 |
189 | if k == 'r':
190 | route = bitstring.BitArray()
191 | for step in v:
192 | pubkey, channel, feebase, feerate, cltv = step
193 | route.append(bitstring.BitArray(pubkey) + bitstring.BitArray(channel) + bitstring.pack('intbe:32', feebase) + bitstring.pack('intbe:32', feerate) + bitstring.pack('intbe:16', cltv))
194 | data += tagged('r', route)
195 | elif k == 'f':
196 | data += encode_fallback(v, addr.currency)
197 | elif k == 'd':
198 | data += tagged_bytes('d', v.encode())
199 | elif k == 'x':
200 | # Get minimal length by trimming leading 5 bits at a time.
201 | expirybits = bitstring.pack('intbe:64', v)[4:64]
202 | while expirybits.startswith('0b00000'):
203 | expirybits = expirybits[5:]
204 | data += tagged('x', expirybits)
205 | elif k == 'h':
206 | data += tagged_bytes('h', hashlib.sha256(v.encode('utf-8')).digest())
207 | elif k == 'n':
208 | data += tagged_bytes('n', v)
209 | else:
210 | # FIXME: Support unknown tags?
211 | raise ValueError("Unknown tag {}".format(k))
212 |
213 | tags_set.add(k)
214 |
215 | # BOLT #11:
216 | #
217 | # A writer MUST include either a `d` or `h` field, and MUST NOT include
218 | # both.
219 | if 'd' in tags_set and 'h' in tags_set:
220 | raise ValueError("Cannot include both 'd' and 'h'")
221 | if not 'd' in tags_set and not 'h' in tags_set:
222 | raise ValueError("Must include either 'd' or 'h'")
223 |
224 | # We actually sign the hrp, then data (padded to 8 bits with zeroes).
225 | privkey = secp256k1.PrivateKey(bytes(unhexlify(privkey)))
226 | sig = privkey.ecdsa_sign_recoverable(bytearray([ord(c) for c in hrp]) + data.tobytes())
227 | # This doesn't actually serialize, but returns a pair of values :(
228 | sig, recid = privkey.ecdsa_recoverable_serialize(sig)
229 | data += bytes(sig) + bytes([recid])
230 |
231 | return bech32_encode(hrp, bitarray_to_u5(data))
232 |
233 | class LnAddr(object):
234 | def __init__(self, paymenthash=None, amount=None, currency='bc', tags=None, date=None):
235 | self.date = int(time.time()) if not date else int(date)
236 | self.tags = [] if not tags else tags
237 | self.unknown_tags = []
238 | self.paymenthash=paymenthash
239 | self.signature = None
240 | self.pubkey = None
241 | self.currency = currency
242 | self.amount = amount
243 |
244 | def __str__(self):
245 | return "LnAddr[{}, amount={}{} tags=[{}]]".format(
246 | hexlify(self.pubkey.serialize()).decode('utf-8'),
247 | self.amount, self.currency,
248 | ", ".join([k + '=' + str(v) for k, v in self.tags])
249 | )
250 |
251 | def lndecode(a, verbose=False):
252 | hrp, data = bech32_decode(a)
253 | if not hrp:
254 | raise ValueError("Bad bech32 checksum")
255 |
256 | # BOLT #11:
257 | #
258 | # A reader MUST fail if it does not understand the `prefix`.
259 | if not hrp.startswith('ln'):
260 | raise ValueError("Does not start with ln")
261 |
262 | data = u5_to_bitarray(data);
263 |
264 | # Final signature 65 bytes, split it off.
265 | if len(data) < 65*8:
266 | raise ValueError("Too short to contain signature")
267 | sigdecoded = data[-65*8:].tobytes()
268 | data = bitstring.ConstBitStream(data[:-65*8])
269 |
270 | addr = LnAddr()
271 | addr.pubkey = None
272 |
273 | m = re.search("[^\d]+", hrp[2:])
274 | if m:
275 | addr.currency = m.group(0)
276 | amountstr = hrp[2+m.end():]
277 | # BOLT #11:
278 | #
279 | # A reader SHOULD indicate if amount is unspecified, otherwise it MUST
280 | # multiply `amount` by the `multiplier` value (if any) to derive the
281 | # amount required for payment.
282 | if amountstr != '':
283 | addr.amount = unshorten_amount(amountstr)
284 |
285 | addr.date = data.read(35).uint
286 |
287 | while data.pos != data.len:
288 | tag, tagdata, data = pull_tagged(data)
289 |
290 | # BOLT #11:
291 | #
292 | # A reader MUST skip over unknown fields, an `f` field with unknown
293 | # `version`, or a `p`, `h`, or `n` field which does not have
294 | # `data_length` 52, 52, or 53 respectively.
295 | data_length = len(tagdata) / 5
296 |
297 | if tag == 'r':
298 | # BOLT #11:
299 | #
300 | # * `r` (3): `data_length` variable. One or more entries
301 | # containing extra routing information for a private route;
302 | # there may be more than one `r` field, too.
303 | # * `pubkey` (264 bits)
304 | # * `short_channel_id` (64 bits)
305 | # * `feebase` (32 bits, big-endian)
306 | # * `feerate` (32 bits, big-endian)
307 | # * `cltv_expiry_delta` (16 bits, big-endian)
308 | route=[]
309 | s = bitstring.ConstBitStream(tagdata)
310 | while s.pos + 264 + 64 + 32 + 32 + 16 < s.len:
311 | route.append((s.read(264).tobytes(),
312 | s.read(64).tobytes(),
313 | s.read(32).intbe,
314 | s.read(32).intbe,
315 | s.read(16).intbe))
316 | addr.tags.append(('r',route))
317 | elif tag == 'f':
318 | fallback = parse_fallback(tagdata, addr.currency)
319 | if fallback:
320 | addr.tags.append(('f', fallback))
321 | else:
322 | # Incorrect version.
323 | addr.unknown_tags.append((tag, tagdata))
324 | continue
325 |
326 | elif tag == 'd':
327 | addr.tags.append(('d', trim_to_bytes(tagdata).decode('utf-8')))
328 |
329 | elif tag == 'h':
330 | if data_length != 52:
331 | addr.unknown_tags.append((tag, tagdata))
332 | continue
333 | addr.tags.append(('h', trim_to_bytes(tagdata)))
334 |
335 | elif tag == 'x':
336 | addr.tags.append(('x', tagdata.uint))
337 |
338 | elif tag == 'p':
339 | if data_length != 52:
340 | addr.unknown_tags.append((tag, tagdata))
341 | continue
342 | addr.paymenthash = trim_to_bytes(tagdata)
343 |
344 | elif tag == 'n':
345 | if data_length != 53:
346 | addr.unknown_tags.append((tag, tagdata))
347 | continue
348 | addr.pubkey = secp256k1.PublicKey(flags=secp256k1.ALL_FLAGS)
349 | addr.pubkey.deserialize(trim_to_bytes(tagdata))
350 | else:
351 | addr.unknown_tags.append((tag, tagdata))
352 |
353 | if verbose:
354 | print('hex of signature data (32 byte r, 32 byte s): {}'
355 | .format(hexlify(sigdecoded[0:64])))
356 | print('recovery flag: {}'.format(sigdecoded[64]))
357 | print('hex of data for signing: {}'
358 | .format(hexlify(bytearray([ord(c) for c in hrp])
359 | + data.tobytes())))
360 | print('SHA256 of above: {}'.format(hashlib.sha256(bytearray([ord(c) for c in hrp]) + data.tobytes()).hexdigest()))
361 |
362 | # BOLT #11:
363 | #
364 | # A reader MUST check that the `signature` is valid (see the `n` tagged
365 | # field specified below).
366 | if addr.pubkey: # Specified by `n`
367 | # BOLT #11:
368 | #
369 | # A reader MUST use the `n` field to validate the signature instead of
370 | # performing signature recovery if a valid `n` field is provided.
371 | addr.signature = addr.pubkey.ecdsa_deserialize_compact(sigdecoded[0:64])
372 | if not addr.pubkey.ecdsa_verify(bytearray([ord(c) for c in hrp]) + data.tobytes(), addr.signature):
373 | raise ValueError('Invalid signature')
374 | else: # Recover pubkey from signature.
375 | addr.pubkey = secp256k1.PublicKey(flags=secp256k1.ALL_FLAGS)
376 | addr.signature = addr.pubkey.ecdsa_recoverable_deserialize(
377 | sigdecoded[0:64], sigdecoded[64])
378 | addr.pubkey.public_key = addr.pubkey.ecdsa_recover(
379 | bytearray([ord(c) for c in hrp]) + data.tobytes(), addr.signature)
380 |
381 | return addr
382 |
--------------------------------------------------------------------------------
/backend/boltathon/views/templates.py:
--------------------------------------------------------------------------------
1 | from flask import Blueprint, session, request, current_app, url_for, g, jsonify, send_file
2 | from flask_cors import cross_origin
3 | from boltathon.models.user import User
4 | from boltathon.models.tip import Tip, tip_schema
5 | from boltathon.extensions import db
6 | from boltathon.util.auth import requires_auth
7 | from boltathon.util.errors import RequestError
8 | from boltathon.util.node import make_invoice, get_pubkey_from_credentials, lookup_invoice
9 | from sqlalchemy import func
10 | import io
11 | import random
12 |
13 | blueprint = Blueprint("templates", __name__, url_prefix="/")
14 |
15 | @blueprint.after_request
16 | def cache_headers(response):
17 | h = {
18 | 'Cache-Control': 'no-cache, no-store',
19 | 'Pragma': 'no-cache',
20 | 'Expire': 'Mon, 01 Jan 1990 00:00:00 GMT',
21 | 'Last-Modified': 'Mon, 01 Jan 2999 00:00:00 GMT',
22 | 'Etag': random.randint(1, 1000000000000),
23 | }
24 | response.headers.extend(h)
25 | return response
26 |
27 | @blueprint.route('/tips/')
28 | def get_pending_tip(tip_id):
29 | tip = Tip.query.get(tip_id)
30 | if tip:
31 | if tip.amount is None:
32 | receiver_user = tip.receiver
33 | invoice = lookup_invoice(tip.rhash, receiver_user.node_url, receiver_user.macaroon, receiver_user.cert)
34 | if invoice.amt_paid_msat:
35 | tip.amount = invoice.amt_paid_msat
36 | db.session.commit()
37 | return jsonify(tip_schema.dump(tip))
38 | raise RequestError(code=404, message="No tip with that ID")
39 |
40 |
41 | @blueprint.route('/users//new_invoice')
42 | def new_invoice(user_id):
43 | user = User.query.filter_by(id=user_id).first()
44 | if not user:
45 | raise RequestError(code=404, message="No user with that ID")
46 | from_name = request.args.get('from')
47 | message = request.args.get('message')
48 |
49 | invoice = make_invoice(user.macaroon, user.node_url, user.cert)
50 |
51 | pending_tip = Tip(from_name, message, None, invoice.rhash)
52 | db.session.add(pending_tip)
53 | db.session.commit()
54 |
55 | return 'Pay with Lightning, tip no. {} '.format(invoice.payment_request, pending_tip.id)
56 |
57 |
58 | @blueprint.route('/users//top_donors')
59 | def top_donors(receiver_id):
60 | return jsonify({
61 | "anon": anonymous_tip_total(receiver_id),
62 | "top": top_donors_total(receiver_id),
63 | })
64 |
65 | def anonymous_tip_total(receiver_id):
66 | return db.session.query(func.sum(Tip.amount))\
67 | .filter_by(receiver_id=receiver_id, sender=None)\
68 | .filter(Tip.amount.isnot(None))\
69 | .first()
70 |
71 | def top_donors_total(receiver_id, limit=5):
72 | return db.session.query(Tip.sender, func.sum(Tip.amount))\
73 | .filter_by(receiver_id=receiver_id)\
74 | .filter(Tip.amount.isnot(None))\
75 | .filter(Tip.sender.isnot(None))\
76 | .filter(Tip.sender != '')\
77 | .group_by(Tip.sender)\
78 | .limit(limit)\
79 | .all()
80 |
81 | @blueprint.route('/users//top_donors.svg')
82 | @cross_origin()
83 | def top_donors_svg(receiver_id):
84 | top_tips = top_donors_total(receiver_id, 3)
85 | print(top_tips)
86 | tip_strings = ['{} - {} sats'.format(t[0], t[1]) for t in top_tips]
87 | tip_strings.extend(['This could be you '] * (3 - len(tip_strings)))
88 |
89 | svg = '''
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 | {}
209 |
210 |
211 | {}
212 |
213 |
214 | {}
215 |
216 |
217 |
218 | '''.format(*tip_strings)
219 |
220 | return send_file(io.BytesIO(bytes(svg, 'utf-8')), mimetype='image/svg+xml')
221 |
222 | @blueprint.route('/manifest.json')
223 | @cross_origin()
224 | def manifest():
225 | return current_app.send_static_file('manifest.json')
226 |
227 | @blueprint.route('/', defaults={'path': ''})
228 | @blueprint.route('/')
229 | def index(path):
230 | if current_app.config.get('ENV') == 'development':
231 | return 'Development mode frontend should be accessed via webpack-dev-server'
232 | return current_app.send_static_file('index.html')
233 |
--------------------------------------------------------------------------------