If you want to unsubscribe from these updates, log into your account here.
8 |
--------------------------------------------------------------------------------
/app/templates/email/reminder.txt:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 |
3 | Your support this past month has been greatly appreciated.
4 |
5 | Your subscription to {{ site }} expires on {{ expires }} (UTC).
6 |
7 | To renew another month of patronage, please click here:
8 | {{ url }}
9 |
10 | Thanks!
11 |
--------------------------------------------------------------------------------
/tests/functional/test_users.py:
--------------------------------------------------------------------------------
1 | def test_updates(test_client, init_database):
2 | '''
3 | GIVEN an instance of LibrePatron
4 | WHEN the updates page is requested
5 | THEN check to make sure updates page is protected
6 | '''
7 | response = test_client.get('/updates')
8 | assert response.status_code != 200
9 |
--------------------------------------------------------------------------------
/app/templates/email/cc_declined.txt:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 |
3 | Your support this past month has been greatly appreciated.
4 |
5 | Your subscription to {{ site }} expires on {{ expires }} (UTC).
6 |
7 | Your credit card was declined this past month. To renew another month of patronage, please click here:
8 | {{ url }}
9 |
10 | Thanks!
11 |
--------------------------------------------------------------------------------
/app/templates/email/reminder_cc.txt:
--------------------------------------------------------------------------------
1 | Dear {{ user.username }},
2 |
3 | Your support this past month has been greatly appreciated.
4 |
5 | Your subscription to {{ site }} expires on {{ expires }} (UTC).
6 |
7 | Your price plan has been discontinued. To renew another month of patronage, please click here to choose a new plan:
8 | {{ url }}
9 |
10 | Thanks!
11 |
--------------------------------------------------------------------------------
/app/templates/auth/register.html:
--------------------------------------------------------------------------------
1 | {% extends "blogging/base.html" %}
2 | {% import 'bootstrap/wtf.html' as wtf %}
3 |
4 | {% block main %}
5 |
19 | {% endblock %}
20 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG]"
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Additional context**
27 | Add any other context about the problem here.
28 |
--------------------------------------------------------------------------------
/app/templates/admin/index.html:
--------------------------------------------------------------------------------
1 | {% extends 'admin/master.html' %}
2 |
3 | {% block body %}
4 | {% if current_user.is_authenticated and current_user.role =='admin'%}
5 |
6 |
After creating an account at squareup.com, go to connect.squareup.com/apps to get this info. If you have not already done so, you will create an app called 'LibrePatron', and then you'll have access to an app id, location id, and access token.
18 |
19 |
To deactivate Square, click below
20 |
WARNING: This will delete all customer recurring payments.
Use the table below to set up your subscription levels.
6 |
7 |
Notes if Square Recurring Billing is Active:
8 |
9 |
Deleting a price level will deactivate recurring credit card billing for all users that that price level. On their renewal date, they'll get an email with a link to choose a new plan.
10 |
Changing the name of a price level will also deactivate recurring credit card billing for all users that that price level. On their renewal date, they'll get an email with a link to choose a new plan.
11 |
Changing the price of a given plan will not alter recurring billing so long as the plan keeps the same name. Your users will be billed the new price, so to not blindside your users it is suggested that you post an update with your new pricing and set said update to be emailed.
12 |
Changing the description of a price level has no effects on recurring billing.
13 |
None of these notes affect users who pay monthly by BTCPay reminder emails.
14 |
15 |
16 | {{ super() }}
17 | {% endblock body %}
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Jeff Vandrew Jr
4 | The code herein incorporates a custom fork of Flask-Blogging, also licensed under the
5 | MIT License.
6 |
7 | Permission is hereby granted, free of charge, to any person obtaining a copy
8 | of this software and associated documentation files (the "Software"), to deal
9 | in the Software without restriction, including without limitation the rights
10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the Software is
12 | furnished to do so, subject to the following conditions:
13 |
14 | The above copyright notice and this permission notice shall be included in all
15 | copies or substantial portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23 | SOFTWARE.
24 |
--------------------------------------------------------------------------------
/deprecated/opt-librepatron.template.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | librepatron:
4 | container_name: librepatron
5 | image: jvandrew/librepatron:0.6.69
6 | expose:
7 | - "8006"
8 | volumes:
9 | - data-volume:/var/lib/db
10 | - config-volume:/var/lib/config
11 | environment:
12 | - SITEURL=https://
13 | - VIRTUAL_HOST=
14 | - LETSENCRYPT_HOST=
15 | - SECRET_KEY_LOCATION=/var/lib/db/key
16 | - LETSENCRYPT_EMAIL=
17 | - DATABASE_URL=sqlite:////var/lib/db/app.db
18 | restart: on-failure
19 |
20 | isso:
21 | container_name: isso
22 | image: jvandrew/isso:atron.22
23 | expose:
24 | - "8080"
25 | environment:
26 | - VIRTUAL_HOST=comments.
27 | - LETSENCRYPT_HOST=comments.
28 | - LETSENCRYPT_EMAIL=
29 | volumes:
30 | - data-volume:/var/lib/db
31 | - config-volume:/var/lib/config
32 | restart: on-failure
33 |
34 | volumes:
35 | data-volume:
36 | driver: local
37 | config-volume:
38 | driver: local
39 |
40 | networks:
41 | generated_default:
42 |
--------------------------------------------------------------------------------
/migrations/versions/33cbe65e05db_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: 33cbe65e05db
4 | Revises: d3d693b8dd81
5 | Create Date: 2019-01-07 23:43:27.837311
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = '33cbe65e05db'
14 | down_revision = 'd3d693b8dd81'
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('price_level',
22 | sa.Column('id', sa.Integer(), nullable=False),
23 | sa.Column('name', sa.String(length=64), nullable=True),
24 | sa.Column('price', sa.Integer(), nullable=True),
25 | sa.Column('description', sa.Text(), nullable=True),
26 | sa.PrimaryKeyConstraint('id')
27 | )
28 | op.create_index(op.f('ix_price_level_name'), 'price_level', ['name'], unique=True)
29 | op.create_index(op.f('ix_price_level_price'), 'price_level', ['price'], unique=False)
30 | # ### end Alembic commands ###
31 |
32 |
33 | def downgrade():
34 | # ### commands auto generated by Alembic - please adjust! ###
35 | op.drop_index(op.f('ix_price_level_price'), table_name='price_level')
36 | op.drop_index(op.f('ix_price_level_name'), table_name='price_level')
37 | op.drop_table('price_level')
38 | # ### end Alembic commands ###
39 |
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # Development
2 |
3 | ## BTCPay Server
4 |
5 | You can pair with your existing production BTCPay Server, or [set one up locally](https://github.com/btcpayserver/btcpayserver-doc/blob/master/LocalDevelopment.md).
6 |
7 | ## Run development server
8 |
9 | Clone the repository.
10 |
11 | Install Python dependencies:
12 |
13 | ```sh
14 | pip3 install flask flask_admin flask_apscheduler flask_login flask_principal flask_fileupload flask_bootstrap flask_migrate flask_ezmail
15 | pip3 install gunicorn apscheduler sqlalchemy
16 | pip3 install markdown python-slugify jwt psutil
17 | pip3 install btcpay
18 | pip3 install squareconnect
19 | ```
20 |
21 | Configure database and settings path:
22 |
23 | ```sh
24 | export ISSO_CONFIG_PATH=$PWD/isso.cfg
25 | export COMMENTS_DB_PATH=$PWD/comments.db
26 | ```
27 |
28 | Create or upgrade the database:
29 |
30 | ```sh
31 | flask db upgrade
32 | ```
33 |
34 | Start the server:
35 |
36 | ```sh
37 | docker_boot.py & gunicorn patron:app
38 | ```
39 | ## Run tests
40 |
41 | Install Python dependencies:
42 |
43 | ```sh
44 | pip3 install pytest
45 | ```
46 |
47 | Configure database path:
48 |
49 | ```sh
50 | export COMMENTS_DB_PATH_TEST=$PWD/comments-test.db
51 | ```
52 |
53 | Create or upgrade the test database:
54 |
55 |
56 | ```sh
57 | flask db upgrade
58 | ```
59 |
60 | Run tests:
61 |
62 | ```sh
63 | python3 -m pytest
64 | ```
65 |
--------------------------------------------------------------------------------
/app/templates/blogging/metatags.html:
--------------------------------------------------------------------------------
1 | {% block meta %}
2 |
9 |
10 |
11 | {% if post.meta.summary %}
12 |
13 |
14 | {% endif %}
15 | {% for image in post.meta.images %}
16 |
17 | {% endfor %}
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | {% endblock meta %}
26 |
--------------------------------------------------------------------------------
/flask_blogging_patron/templates/blogging/metatags.html:
--------------------------------------------------------------------------------
1 | {% block meta %}
2 |
3 |
10 |
11 |
12 | {% if post.meta.summary %}
13 |
14 |
15 | {% endif %}
16 | {% for image in post.meta.images %}
17 |
18 | {% endfor %}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | {% endblock meta %}
27 |
--------------------------------------------------------------------------------
/migrations/versions/fd06815c1b86_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: fd06815c1b86
4 | Revises: a31adf717e90
5 | Create Date: 2019-01-17 20:09:34.525088
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'fd06815c1b86'
14 | down_revision = 'a31adf717e90'
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('email', sa.Column('debug', sa.Boolean(), nullable=True))
22 | op.add_column('email', sa.Column('default_sender', sa.String(length=128), nullable=True))
23 | op.add_column('email', sa.Column('max_emails', sa.Integer(), nullable=True))
24 | op.add_column('email', sa.Column('suppress', sa.Boolean(), nullable=True))
25 | op.add_column('email', sa.Column('use_ssl', sa.Boolean(), nullable=True))
26 | op.add_column('email', sa.Column('use_tls', sa.Boolean(), nullable=True))
27 | # ### end Alembic commands ###
28 |
29 |
30 | def downgrade():
31 | # ### commands auto generated by Alembic - please adjust! ###
32 | op.add_column('email', sa.Column('outgoing_email', sa.VARCHAR(length=128), nullable=True))
33 | op.drop_column('email', 'use_tls')
34 | op.drop_column('email', 'use_ssl')
35 | op.drop_column('email', 'suppress')
36 | op.drop_column('email', 'max_emails')
37 | op.drop_column('email', 'default_sender')
38 | op.drop_column('email', 'debug')
39 | # ### end Alembic commands ###
40 |
--------------------------------------------------------------------------------
/migrations/versions/a9d7af936cb5_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: a9d7af936cb5
4 | Revises: 33cbe65e05db
5 | Create Date: 2019-01-08 15:37:12.725922
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'a9d7af936cb5'
14 | down_revision = '33cbe65e05db'
15 | branch_labels = None
16 | depends_on = None
17 |
18 |
19 | def upgrade():
20 | # ### commands auto generated by Alembic - please adjust! ###
21 | op.drop_index('ix_price_level_price', table_name='price_level')
22 | op.create_index(op.f('ix_price_level_price'), 'price_level', ['price'], unique=True)
23 | op.add_column('user', sa.Column('square_card', sa.String(length=120), nullable=True))
24 | op.add_column('user', sa.Column('square_id', sa.String(length=120), nullable=True))
25 | op.create_index(op.f('ix_user_square_card'), 'user', ['square_card'], unique=False)
26 | op.create_index(op.f('ix_user_square_id'), 'user', ['square_id'], unique=False)
27 | # ### end Alembic commands ###
28 |
29 |
30 | def downgrade():
31 | # ### commands auto generated by Alembic - please adjust! ###
32 | op.drop_index(op.f('ix_user_square_id'), table_name='user')
33 | op.drop_index(op.f('ix_user_square_card'), table_name='user')
34 | op.drop_column('user', 'square_id')
35 | op.drop_column('user', 'square_card')
36 | op.drop_index(op.f('ix_price_level_price'), table_name='price_level')
37 | op.create_index('ix_price_level_price', 'price_level', ['price'], unique=False)
38 | # ### end Alembic commands ###
39 |
--------------------------------------------------------------------------------
/migrations/versions/be7c190835cc_.py:
--------------------------------------------------------------------------------
1 | """empty message
2 |
3 | Revision ID: be7c190835cc
4 | Revises:
5 | Create Date: 2018-12-23 21:19:39.926589
6 |
7 | """
8 | from alembic import op
9 | import sqlalchemy as sa
10 |
11 |
12 | # revision identifiers, used by Alembic.
13 | revision = 'be7c190835cc'
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('username', sa.String(length=64), nullable=True),
24 | sa.Column('email', sa.String(length=120), nullable=True),
25 | sa.Column('password_hash', sa.String(length=128), nullable=True),
26 | sa.Column('expiration', sa.DateTime(), nullable=True),
27 | sa.Column('role', sa.String(length=64), nullable=True),
28 | sa.PrimaryKeyConstraint('id')
29 | )
30 | op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
31 | op.create_index(op.f('ix_user_expiration'), 'user', ['expiration'], unique=False)
32 | op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=True)
33 | # ### end Alembic commands ###
34 |
35 |
36 | def downgrade():
37 | # ### commands auto generated by Alembic - please adjust! ###
38 | op.drop_index(op.f('ix_user_username'), table_name='user')
39 | op.drop_index(op.f('ix_user_expiration'), table_name='user')
40 | op.drop_index(op.f('ix_user_email'), table_name='user')
41 | op.drop_table('user')
42 | # ### end Alembic commands ###
43 |
--------------------------------------------------------------------------------
/app/templates/blogging/index.html:
--------------------------------------------------------------------------------
1 | {% extends "blogging/base.html" %}
2 | {% block title %}
3 | Updates
4 | {% endblock title %}
5 |
6 | {% block main %}
7 | {% if alert %}
8 |
26 |
27 | {{post.rendered_text | safe | truncate(1200)}}
28 |
29 | {% endif %}
30 | {% endfor %}
31 | {% endfor %}
32 | {% if ((meta) and (meta.max_pages>1)) %}
33 |
44 | {% endif %}
45 | {% endblock main %}
46 |
--------------------------------------------------------------------------------
/app/utils.py:
--------------------------------------------------------------------------------
1 | from app import db
2 | from app.models import BTCPayClientStore
3 | from btcpay import BTCPayClient
4 | from btcpay.crypto import generate_privkey
5 | from flask import request
6 | import os
7 | import psutil
8 | import signal
9 | import time
10 | from urllib.parse import urlparse, urljoin
11 |
12 |
13 | def is_safe_url(target):
14 | # prevents malicious redirects
15 | ref_url = urlparse(request.host_url)
16 | test_url = urlparse(urljoin(request.host_url, target))
17 | return test_url.scheme in ('http', 'https') and \
18 | ref_url.netloc == test_url.netloc
19 |
20 |
21 | def pairing(code, host):
22 | # pairs BTCPay
23 | privkey = generate_privkey()
24 | btc_client = BTCPayClient(host=host, pem=privkey)
25 | btc_token = btc_client.pair_client(code)
26 | btc_client = BTCPayClient(host=host, pem=privkey, tokens=btc_token)
27 | client_store = BTCPayClientStore.query.first()
28 | if client_store is None:
29 | client_store = BTCPayClientStore(client=btc_client)
30 | db.session.add(client_store)
31 | else:
32 | client_store.client = btc_client
33 | db.session.commit()
34 |
35 |
36 | def hup_gunicorn():
37 | # reload gunicorn workers, keep gunicorn master process alive
38 | # this allows reloading of flask global vars on the fly
39 | processes = []
40 | for proc in psutil.process_iter(attrs=['pid', 'name']):
41 | if 'gunicorn' in proc.info['name']:
42 | if proc.children():
43 | for child in proc.children():
44 | processes.append(child.pid)
45 | for pid in processes:
46 | os.kill(pid, signal.SIGTERM)
47 | time.sleep(2)
48 |
--------------------------------------------------------------------------------
/tests/tconfig.py:
--------------------------------------------------------------------------------
1 | from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
2 | from datetime import datetime, timedelta
3 | import os
4 | from os.path import abspath, join
5 |
6 | basedir = abspath(os.path.dirname(__file__))
7 |
8 |
9 | class Config(object):
10 | ADMIN = 'test@test.com'
11 | BLOGGING_SITENAME = os.environ.get('SITENAME') or 'LibrePatron'
12 | BLOGGING_SITEURL = os.environ.get('SITEURL') or 'https://example.com'
13 | BLOGGING_URL_PREFIX = '/updates'
14 | BLOGGING_BRANDURL = os.environ.get('BRANDURL')
15 | BLOGGING_TWITTER_USERNAME = os.environ.get('TWITTER')
16 | BLOGGING_DISQUS_SITENAME = os.environ.get('DISQUS')
17 | BLOGGING_GOOGLE_ANALYTICS = os.environ.get('GOOGLE_ANALYTICS')
18 | BLOGGING_PERMISSIONS = True
19 | BLOGGING_PERMISSIONNAME = 'admin'
20 | BLOGGING_PLUGINS = None
21 | BLOGGING_ALLOW_FILE_UPLOAD = True
22 | BLOGGING_ESCAPE_MARKDOWN = False
23 | ISSO_CONFIG_PATH = f'/tmp/{os.urandom(16)}'
24 | COMMENTS_DB_PATH = os.environ.get('COMMENTS_DB_PATH_TEST') or '/var/lib/db/comments.db'
25 | PREFERRED_URL_SCHEME = 'https'
26 | SCHEDULER_BASE = datetime.now() + timedelta(minutes=1)
27 | SCHEDULER_HOUR = SCHEDULER_BASE.hour
28 | SCHEDULER_MINUTE = SCHEDULER_BASE.minute
29 | SECRET_KEY = 'a-very-secret-key'
30 | SECRET_KEY_LOCATION = f'/tmp/{os.urandom(16)}'
31 | SQLALCHEMY_DATABASE_URI = 'sqlite:///' + join(basedir, 'app_test.db')
32 | SCHEDULER_JOBSTORES = {
33 | 'default': SQLAlchemyJobStore(url=SQLALCHEMY_DATABASE_URI)
34 | }
35 | SQLALCHEMY_TRACK_MODIFICATIONS = False
36 | THEME = 'spacelab'
37 | SERVER_NAME = 'librepatron.com'
38 | BCRYPT_LOG_ROUNDS = 4
39 | TESTING = True
40 | WTF_CSRF_ENABLED = False
41 |
--------------------------------------------------------------------------------
/deprecated/luna-installer.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | if [ "$(id -u)" != "0" ]; then
4 | echo "This installer must be run as root."
5 | echo "Use the command 'sudo su -' (include the trailing hypen) and try again"
6 | exit 1
7 | fi
8 |
9 | (return 2>/dev/null) && sourced=1 || sourced=0
10 |
11 | if [ $sourced != 1 ]; then
12 | echo "You forgot the leading '.' followed by a space!"
13 | echo "Try this format: . ./luna-installer.sh example.com email@email.com"
14 | exit 1
15 | fi
16 |
17 | if [ -z ${1+x} ]; then
18 | echo "You forgot to add domain and email!"
19 | echo "Try again, in this format: ./luna-installer.sh example.com email@email.com"
20 | exit 1
21 | elif [ -z ${2+x} ]; then
22 | echo "You forgot to add domain and email!"
23 | echo "Try again, in this format: ./luna-installer.sh example.com email@email.com"
24 | exit 1
25 | fi
26 |
27 | host=$1
28 | email=$2
29 | file="opt-librepatron.custom.yml"
30 |
31 | wget https://raw.githubusercontent.com/JeffVandrewJr/patron/master/opt-librepatron.template.yml
32 | cat opt-librepatron.template.yml > $file
33 | rm opt-librepatron.template.yml
34 |
35 | sed -i "s//$host/g" $file
36 | sed -i "s//$email/g" $file
37 |
38 | mv $file /root/btcpayserver-docker/docker-compose-generator/docker-fragments
39 |
40 | if [[ $BTCPAYGEN_ADDITIONAL_FRAGMENTS == *"opt-librepatron.custom.yml"* ]]; then
41 | echo "BTCPAYGEN_ADDITIONAL_FRAGMENTS is already properly set."
42 | elif [ -z ${BTCPAYGEN_ADDITIONAL_FRAGMENTS+x} ]; then
43 | export BTCPAYGEN_ADDITIONAL_FRAGMENTS="opt-librepatron.custom.yml"
44 | else
45 | export BTCPAYGEN_ADDITIONAL_FRAGMENTS="${BTCPAYGEN_ADDITIONAL_FRAGMENTS};opt-librepatron.custom.yml"
46 | fi
47 |
48 | cd /root/btcpayserver-docker
49 |
50 | . ./btcpay-setup.sh -i
51 |
--------------------------------------------------------------------------------
/SSH.md:
--------------------------------------------------------------------------------
1 | Here are instructions on how to access your LunaNode via SSH. I stole them from bitcoinshirt!
2 |
3 |
Connect via SSH to your Virtual Machine
4 |
5 | Log into LunaNode to grab your IP address and password. To do this, go to https://lunanode.com, log in, and then go to this screen:
6 |
7 | 
8 |
9 | You're now ready to log into your VPS via SSH.
10 |
11 |
12 |
13 |
SSH Instructions for Mac, Linux, and other Unix-Like Systems
14 |
15 | If you are on Max, Linux, or another Unix-Like system, the first step is to open a terminal. Thenn simply enter the following command:
16 | ```bash
17 | # fill in the x's with your LunaNode's EXTERNAL IP address as shown on the screen above
18 | ssh ubuntu@xxx.xx.x.x
19 | ```
20 | You'll then be prompted for your password. This is NOT the password you used to log into the LunaNode website. It is the password shown on the screen above (yours will obviously be different from the password on the above screenshot).
21 |
22 |
23 |
24 |
SSH Instructions for Windows
25 |
26 | If you are on Windows, instead of the commands above, download and install Putty [(click here to download)](https://www.chiark.greenend.org.uk/~sgtatham/putty/latest.html) and copy/paste your LunaNode External IP Address in the "Host Name (Or IP Address)" box:
27 |
28 | 
29 |
30 | Reminder: Your LunaNode external IP address will be a series of numbers with dots in between, as shown in the General Tab in the very top photo. When you connect you will be prompted to input a password, which you can find it in the General tab of your Virtual Machine (as shown in the very top photo). This is not the password that you used to log into lunanode.com.
31 |
--------------------------------------------------------------------------------
/app/templates/main/homepage.html:
--------------------------------------------------------------------------------
1 | {% extends "blogging/base.html" %}
2 | {% block meta %}
3 | {% include 'blogging/metatags.html' %}
4 | {% endblock meta %}
5 | {% block title %}
6 | {{post.title}}
7 | {% endblock title %}
8 |
9 | {% block main %}
10 | {% if meta.is_user_blogger %}
11 |
2 |
3 | If your admin panel shows a version less than the version above, follow one of the two sets of instructions below to upgrade. Which set of instructions you choose will be determined by the method you used to originally install LibrePatron.
4 |
5 |
Upgrading to Current Version if You Installed through BTCPay
6 |
7 | If you originally installed using the official BTCPay installer, you would simply update your BTCPay server and your LibrePatron should update right alongside it.
8 |
9 | To do that, you simply log into BTCPay, then hit Server Settings, Maintenance, then Update.
10 |
11 | Note that there can be a short delay before the latest upgrade is available through the BTCPay update system.
12 |
13 |
Upgrading to Current Version if you Did Not Install Through BTCPay
14 |
15 | If you didn't originally install through BTCPay and your LibrePatron instance lives on a server separate from your BTCPay instance, you'd instead use docker-compose.
16 |
17 | ```bash
18 | # if you have an old docker-compose.yml file, first delete it
19 | rm docker-compose.yml
20 |
21 | wget https://raw.githubusercontent.com/JeffVandrewJr/patron/master/alternate_install/librepatron.env
22 |
23 | # open librepatron.env and fill in the necessary info as mentioned in the file comments, and then save
24 | nano librepatron.env
25 |
26 | wget https://raw.githubusercontent.com/JeffVandrewJr/patron/master/alternate_install/isso.env
27 |
28 | # open isso.env and fill in the necessary info as mentioned in the file comments, and then save
29 | nano isso.env
30 |
31 | wget https://raw.githubusercontent.com/JeffVandrewJr/patron/master/docker-compose.yml
32 | sudo docker-compose up -d
33 | ```
34 | Your site will then be launched and operational! if you're upgrading from a version prior to 0.6.26, you'll need to reset your price levels and email settings from the web interface admin panel, as price levels and emails settings are now set from the web interface rather than a config file.
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | #persistent storage
2 | *.db
3 | data
4 | dump.rdb
5 | key
6 |
7 | # config
8 | *.conf
9 | *.cfg
10 | pricing.yaml
11 |
12 | # Byte-compiled / optimized / DLL files
13 | __pycache__/
14 | *.py[cod]
15 | *$py.class
16 |
17 | # C extensions
18 | *.so
19 |
20 | # Distribution / packaging
21 | .Python
22 | build/
23 | develop-eggs/
24 | dist/
25 | downloads/
26 | eggs/
27 | .eggs/
28 | lib/
29 | lib64/
30 | parts/
31 | sdist/
32 | var/
33 | wheels/
34 | *.egg-info/
35 | .installed.cfg
36 | *.egg
37 | MANIFEST
38 |
39 | # PyInstaller
40 | # Usually these files are written by a python script from a template
41 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
42 | *.manifest
43 | *.spec
44 |
45 | # Installer logs
46 | pip-log.txt
47 | pip-delete-this-directory.txt
48 |
49 | # Unit test / coverage reports
50 | htmlcov/
51 | .tox/
52 | .nox/
53 | .coverage
54 | .coverage.*
55 | .cache
56 | nosetests.xml
57 | coverage.xml
58 | *.cover
59 | .hypothesis/
60 | .pytest_cache/
61 |
62 | # Translations
63 | *.mo
64 | *.pot
65 |
66 | # Django stuff:
67 | *.log
68 | local_settings.py
69 | db.sqlite3
70 |
71 | # Flask stuff:
72 | instance/
73 | .webassets-cache
74 | .flaskenv
75 |
76 | # Scrapy stuff:
77 | .scrapy
78 |
79 | # Sphinx documentation
80 | docs/_build/
81 |
82 | # PyBuilder
83 | target/
84 |
85 | # Jupyter Notebook
86 | .ipynb_checkpoints
87 |
88 | # IPython
89 | profile_default/
90 | ipython_config.py
91 |
92 | # pyenv
93 | .python-version
94 |
95 | # celery beat schedule file
96 | celerybeat-schedule
97 |
98 | # SageMath parsed files
99 | *.sage.py
100 |
101 | # Environments
102 | .env
103 | .venv
104 | env/
105 | venv/
106 | ENV/
107 | env.bak/
108 | venv.bak/
109 |
110 | # Spyder project settings
111 | .spyderproject
112 | .spyproject
113 |
114 | # Rope project settings
115 | .ropeproject
116 |
117 | # mkdocs documentation
118 | /site
119 |
120 | # mypy
121 | .mypy_cache/
122 | .dmypy.json
123 | dmypy.json
124 |
125 | # Pyre type checker
126 | .pyre/
127 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 |
3 | jobs:
4 | build:
5 | docker:
6 | - image: jvandrew/librepatron:0.1.28
7 | steps:
8 | - run:
9 | command: |
10 | apk add git
11 | git clone https://github.com/JeffVandrewJr/patron.git
12 | pip install pytest pytest-flask-sqlalchemy pytest-mock
13 | cd patron
14 | python3 -m pytest
15 |
16 | # Everytimes if a tag of specific format is push on git, a docker image is created and pushed to dockerhub
17 | # eg. You tag v1.0, then the docker image $DOCKERHUB_REPO:1.0 will be built and push to docker hub.
18 | # Requires: $DOCKERHUB_USER, $DOCKERHUB_PASS, $DOCKERHUB_REPO defined
19 | publish_linuxamd64:
20 | machine:
21 | docker_layer_caching: true
22 | steps:
23 | - checkout
24 | - run:
25 | command: |
26 | LATEST_TAG="${CIRCLE_TAG:1}"
27 | DOCKERHUB_DESTINATION="$DOCKERHUB_REPO:$LATEST_TAG-amd64"
28 | DOCKERHUB_DOCKEFILE="Dockerfile"
29 | #
30 | echo "Pushing $DOCKERHUB_DOCKEFILE to dockerhub repository $DOCKERHUB_DESTINATION"
31 | sudo docker login --username=$DOCKERHUB_USER --password=$DOCKERHUB_PASS
32 | sudo docker build --pull -t "$DOCKERHUB_DESTINATION" -f "$DOCKERHUB_DOCKEFILE" .
33 | sudo docker push $DOCKERHUB_DESTINATION
34 | # Push a manifest like image, we support only amd64 now, so no need to create real manifest
35 | DOCKERHUB_DESTINATION="$DOCKERHUB_REPO:$LATEST_TAG"
36 | sudo docker tag "$DOCKERHUB_DESTINATION-amd64" "$DOCKERHUB_DESTINATION"
37 | sudo docker push "$DOCKERHUB_DESTINATION"
38 | workflows:
39 | version: 2
40 | build_and_test:
41 | jobs:
42 | - build:
43 | filters:
44 | branches:
45 | ignore: /.*/
46 | tags:
47 | only: /^test.*/
48 | publish:
49 | jobs:
50 | - publish_linuxamd64:
51 | filters:
52 | branches:
53 | ignore: /.*/
54 | tags:
55 | only: /v[0-9]+(\.[0-9]+)*/
56 |
--------------------------------------------------------------------------------
/flask_blogging_patron/templates/blogging/index.html:
--------------------------------------------------------------------------------
1 | {% extends "blogging/base.html" %}
2 | {% block title %}
3 | Blog Posts
4 | {% endblock title %}
5 |
6 | {% block main %}
7 | {% if alert %}
8 |
62 | {% include "blogging/analytics.html" %}
63 | {% block js %}
64 |
65 |
66 |
67 |
76 | {% endblock js %}
77 | {% block extrajs %}
78 | {% endblock extrajs %}
79 |
80 |
81 |
--------------------------------------------------------------------------------
/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 | except Exception as exception:
82 | logger.error(exception)
83 | raise exception
84 | finally:
85 | connection.close()
86 |
87 | if context.is_offline_mode():
88 | run_migrations_offline()
89 | else:
90 | run_migrations_online()
91 |
--------------------------------------------------------------------------------
/alternate_install/alternate-install-docker.md:
--------------------------------------------------------------------------------
1 |
Alternate Install via Docker-Compose
2 |
3 | If you're not using the installer script mentioned in the README [(click here to see the README)](https://github.com/JeffVandrewJr/patron/blob/master/README.md), a docker-compose is provided that automatically installs LibrePatron along with nginx and obtains SSL certificates. You do not need to do anything in this section if you used the LunaNode installer.
4 |
5 | Before installing, don't forget to point your domain's DNS to your server's address. (You perform this step with your domain registrar: GoDaddy, NameCheap, etc.) You must point both the main domain and the `comments` subdomain. So if you're hosting LibrePatron at `example.com`, both `example.com` and `comments.example.com` must point to your server address. Here are the steps (to be executed from `$HOME` directory):
6 |
7 | ```bash
8 | wget https://raw.githubusercontent.com/JeffVandrewJr/patron/master/alternate_install/librepatron.env
9 |
10 | # open librepatron.env and fill in the necessary info as mentioned in the file comments, and then save
11 | nano librepatron.env
12 |
13 | wget https://raw.githubusercontent.com/JeffVandrewJr/patron/master/alternate_install/isso.env
14 |
15 | # open isso.env and fill in the necessary info as mentioned in the file comments, and then save
16 | nano isso.env
17 |
18 | sudo docker network create nginx-net
19 | wget https://raw.githubusercontent.com/JeffVandrewJr/patron/master/docker-compose.yml
20 | sudo docker-compose up -d
21 | ```
22 | Your site will then be launched and operational! You can upgrade from a prior version by executing the same steps above. Just make sure you delete your old `docker-compose.yml` first. if you're upgrading from a version prior to 0.6.26, you'll need to reset your price levels and email settings from the web interface admin panel, as price levels and emails settings are now set from the web interface rather than a config file. You'll also need the new isso.env file if you're upgrading from a version prior to 0.6.26.
23 |
24 |
Post-Install Setup
25 |
26 | The first visitor to the site will be prompted to register as administrator. The administrator is the user that posts updates, gets paid, etc. The administrator is the content creator.
27 |
28 | Heading to the admin panel should be your first step after registering as the admin, as the site will not function properly until email and BTCPay Server settings are filled in. Square settings for accepting fiat are optional, as are the settings for Google Analytics and user comments. BTCPay pairing and email setup are mandatory, and your site will malfunction without them.
29 |
30 | You'll need SMTP server info for the email section. Gmail, Yahoo, etc are not good servers to use for this purpose, as they block bulk emails. If you don't have SMTP settings to use, here's an example of an easy to use service that would work: https://www.easy-smtp.com/ (free for 10,000 emails per month).
31 |
32 | Your users will get a 5 hour subscription as soon as they pay their BTCPay invoice. That is bumped to 30 days as soon as BTCPay recognizes the payment as "confirmed". BTCPay settings determine how many confirmations are required to make a payment "confirmed."
33 |
34 | If you decide to allow fiat payments, after setting up square, it is suggested that you run a [test charge by follwing these instructions](https://github.com/JeffVandrewJr/patron/blob/master/TEST-CC-CHARGE.md).
35 |
--------------------------------------------------------------------------------
/flask_blogging_patron/templates/blogging/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {% block meta %}
6 | {% endblock meta %}
7 | {% block style %}
8 |
9 |
12 | {% endblock style %}
13 | {% block extrastyle %}
14 | {% endblock extrastyle %}
15 |
16 | {% block title %}
17 | {% endblock title %}
18 |
19 |
20 |
21 |
22 |
23 |
75 | {% block js %}
76 |
77 |
78 |
79 | {% endblock js %}
80 | {% block extrajs %}
81 | {% endblock extrajs %}
82 |
83 |
84 |
--------------------------------------------------------------------------------
/flask_blogging_patron/storage.py:
--------------------------------------------------------------------------------
1 | try:
2 | from builtins import object
3 | except ImportError:
4 | pass
5 |
6 |
7 | class Storage(object):
8 |
9 | def save_post(self, title, text, user_id, tags, draft=False,
10 | post_date=None, last_modified_date=None, meta_data=None,
11 | post_id=None):
12 | """
13 | Persist the blog post data. If ``post_id`` is ``None`` or ``post_id``
14 | is invalid, the post must be inserted into the storage. If ``post_id``
15 | is a valid id, then the data must be updated.
16 |
17 | :param title: The title of the blog post
18 | :type title: str
19 | :param text: The text of the blog post
20 | :type text: str
21 | :param user_id: The user identifier
22 | :type user_id: str
23 | :param tags: A list of tags
24 | :type tags: list
25 | :param draft: If the post is a draft of if needs to be published.
26 | :type draft: bool
27 | :param post_date: (Optional) The date the blog was posted (default
28 | datetime.datetime.utcnow())
29 | :type post_date: datetime.datetime
30 | :param last_modified_date: (Optional) The date when blog was last
31 | modified (default datetime.datetime.utcnow())
32 | :type last_modified_date: datetime.datetime
33 | :param meta_data: The meta data for the blog post
34 | :type meta_data: dict
35 | :param post_id: The post identifier. This should be ``None`` for an
36 | insert call, and a valid value for update.
37 | :type post_id: int
38 |
39 | :return: The post_id value, in case of a successful insert or update.
40 | Return ``None`` if there were errors.
41 | """
42 | raise NotImplementedError("This method needs to be implemented by "
43 | "the inheriting class")
44 |
45 | def get_post_by_id(self, post_id):
46 | """
47 | Fetch the blog post given by ``post_id``
48 |
49 | :param post_id: The post identifier for the blog post
50 | :type post_id: int
51 | :return: If the ``post_id`` is valid, the post data is retrieved,
52 | else returns ``None``.
53 | """
54 | raise NotImplementedError("This method needs to be implemented by the "
55 | "inheriting class")
56 |
57 | def get_posts(self, count=10, offset=0, recent=True, tag=None,
58 | user_id=None, include_draft=False):
59 | """
60 | Get posts given by filter criteria
61 |
62 | :param count: The number of posts to retrieve (default 10). If count
63 | is ``None``, all posts are returned.
64 | :type count: int
65 | :param offset: The number of posts to offset (default 0)
66 | :type offset: int
67 | :param recent: Order by recent posts or not
68 | :type recent: bool
69 | :param tag: Filter by a specific tag
70 | :type tag: str
71 | :param user_id: Filter by a specific user
72 | :type user_id: str
73 | :param include_draft: Whether to include posts marked as draft or not
74 | :type include_draft: bool
75 |
76 | :return: A list of posts, with each element a dict containing values
77 | for the following keys: (title, text, draft, post_date,
78 | last_modified_date). If count is ``None``, then all the posts are
79 | returned.
80 | """
81 | raise NotImplementedError("This method needs to be implemented by the "
82 | "inheriting class")
83 |
84 | def count_posts(self, tag=None, user_id=None, include_draft=False):
85 | """
86 | Returns the total number of posts for the give filter
87 |
88 | :param tag: Filter by a specific tag
89 | :type tag: str
90 | :param user_id: Filter by a specific user
91 | :type user_id: str
92 | :param include_draft: Whether to include posts marked as draft or not
93 | :type include_draft: bool
94 | :return: The number of posts for the given filter.
95 | """
96 | raise NotImplementedError("This method needs to be implemented by the "
97 | "inheriting class")
98 |
99 | def delete_post(self, post_id):
100 | """
101 | Delete the post defined by ``post_id``
102 |
103 | :param post_id: The identifier corresponding to a post
104 | :type post_id: int
105 | :return: Returns True if the post was successfully deleted and False
106 | otherwise.
107 | """
108 | raise NotImplementedError("This method needs to be implemented by the "
109 | "inheriting class")
110 |
111 | @classmethod
112 | def normalize_tags(cls, tags):
113 | return [cls.normalize_tag(tag) for tag in tags]
114 |
115 | @staticmethod
116 | def normalize_tag(tag):
117 | return tag.upper().strip()
118 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
LibrePatron: A Self-Hosted Patreon Alternative Backed by BTCPay
2 |
3 | Copyright (C) 2018-2020 Jeff Vandrew Jr
4 |
5 | x64 Only. ARM (ie Raspberry Pi) is not supported.
6 |
7 | No longer under active development.
8 |
9 | Latest Stable Release: 0.7.39
10 |
11 | Patreon is a popular service that allows content creators to receive contributions from supporters on a recurring basis. Unfortunately, Patreon is also a dedicated enemy of the concept of free speech as an important civic virtue. Patreon is known to arbitrarily ban its creators for "thought crime."
12 |
13 | Unfortunately most Patreon alternatives to date do not implement all of Patreon's main features, namely:
14 |
15 | * Support for both Bitcoin (BTCPay Server) and optionally credit cards (Square)
16 | * Main page to entice new subscribers
17 | * Google Analytics
18 | * Protected page to post updates (viewable by subscribers only)
19 | * Automatic bulk emailing of updates to subscribers
20 | * Managing billing and subscription expiration
21 | * Automatic monthly billing via email
22 | * User commenting on updates
23 | * 21 themes and color schemes to choose from
24 |
25 | Portions of this package rely on a fork of the Flask-Blogging package by Gouthaman Balaraman.
26 |
27 | If you're a creator reading this unconcerned with free speech issues, Patreon still takes a percentage of your earnings, which can be avoided by using LibrePatron.
28 |
29 |
Improvements Roadmap
30 |
31 | 1. More granular control over subscription levels.
32 | 2. Right now, user comments only show when you click on an inidividual creator update in the "Updates" list. This should be improves so there is an indicator that a post has user comments even before it is clicked.
33 |
34 |
Easy Install
35 |
36 | You first need a BTCPay installation. If you have not yet installed BTCPay, [here](https://docs.btcpayserver.org/deployment/lunanodewebdeployment) are instructions to get BTCPay set up.
37 |
38 | You can also find an illustrated version of these instructions [here](https://blog.btcpayserver.org/librepatron-patreon-alternative/).
39 |
40 | If you set up BTCPay using the one-click LunaNode install (or any dockerized install of BTCPay), to set up LibrePatron you would simply SSH into your LunaNode [(click here if you forgot how to do that)](https://github.com/JeffVandrewJr/patron/blob/master/SSH.md), and then:
41 | ```bash
42 | # change to root; do not forget the trailing hyphen
43 | sudo su -
44 |
45 | cd btcpayserver-docker
46 |
47 | export BTCPAYGEN_ADDITIONAL_FRAGMENTS="$BTCPAYGEN_ADDITIONAL_FRAGMENTS;opt-add-librepatron"
48 |
49 | # replace example.com with the domain where you want to host LibrePatron
50 | export LIBREPATRON_HOST="example.com"
51 |
52 | . btcpay-setup.sh -i
53 | ```
54 |
55 | That's it! You would replace `example.com` with the domain where you wish to host LibrePatron. Also make sure that domain points to the same IP address as the domain you use for BTCPay. (This would be set with your domain host: GoDaddy, NameCheap, etc).
56 |
57 | You only ever need to do that setup once, as from then on LibrePatron will update alongside BTCPay.
58 |
59 | If you didn't use the LunaNode one-click install, the same instructions above apply so long as you're using the dockerized version of BTCPay.
60 |
61 | If you wish to install separately from BTCPay for whatever reason, see the alternate instructions in the 'alternate_install' directory.
62 |
63 | In the future, you can upgrade by simply upgrading BTCPay; LibrePatron will upgrade right alongside it. Just log into BTCPay through the web, then go to Server Settings --> Maintenance --> Update.
64 |
65 | IMPORTANT: Before advertising your site, see the section on post-install setup below.
66 |
67 |
Post-Install Setup
68 |
69 | The first visitor to the site will be prompted to register as administrator. The administrator is the user that posts updates, gets paid, etc. The administrator is the content creator.
70 |
71 | Heading to the admin panel should be your first step after registering as the admin, as the site will not function properly until email and BTCPay Server settings are filled in. Square settings for accepting fiat are optional, as are the settings for Google Analytics and user comments. BTCPay pairing and email setup are mandatory, and your site will malfunction without them.
72 |
73 | You'll need SMTP server info for the email section. Gmail, Yahoo, etc are not good servers to use for this purpose, as they block bulk emails. If you don't have SMTP settings to use, here's an example of an easy to use service that would work: https://www.easy-smtp.com/ (free for 10,000 emails per month).
74 |
75 | Your users will get a 5 hour subscription as soon as they pay their BTCPay invoice. That is bumped to 30 days as soon as BTCPay recognizes the payment as "confirmed". BTCPay settings determine how many confirmations are required to make a payment "confirmed."
76 |
77 | If you decide to allow fiat payments, after setting up square, it is suggested that you run a [test charge by following these instructions](https://github.com/JeffVandrewJr/patron/blob/master/TEST-CC-CHARGE.md).
78 |
79 |
Development
80 |
81 | See [DEVELOPMENT.md](DEVELOPMENT.md) if you wish to run a local instance.
82 |
--------------------------------------------------------------------------------
/app/tasks.py:
--------------------------------------------------------------------------------
1 | from app import scheduler, db, SCHEDULER_HOUR, SCHEDULER_MINUTE
2 | from app.email import send_reminder_emails, send_failed_emails
3 | from app.models import User, Square, PriceLevel
4 | from datetime import datetime, timedelta
5 | import shelve
6 | from squareconnect.api_client import ApiClient
7 | from squareconnect.apis.transactions_api import TransactionsApi
8 | import uuid
9 |
10 | '''
11 | Registers all BTCPay and Square renewal tasks to run daily.
12 | Uses Flask-APScheduler.
13 | '''
14 |
15 |
16 | @scheduler.task(
17 | 'cron',
18 | id='do_renewals',
19 | hour=SCHEDULER_HOUR,
20 | minute=SCHEDULER_MINUTE,
21 | misfire_grace_time=10800,
22 | )
23 | def renewals():
24 | with shelve.open(scheduler.app.config['SECRET_KEY_LOCATION']) as storage:
25 | begin = storage['last_renewal']
26 | renewals_btcpay(begin)
27 | renewals_square(begin)
28 | with shelve.open(scheduler.app.config['SECRET_KEY_LOCATION']) as storage:
29 | storage['last_renewal'] = datetime.today()
30 |
31 |
32 | def renewals_btcpay(begin):
33 | tomorrow = datetime.today() + timedelta(hours=24)
34 | scheduler.app.logger.info('Starting BTCPay renewals')
35 | with scheduler.app.app_context():
36 | last_reminder = User.query.filter(
37 | User.expiration < tomorrow,
38 | User.expiration > begin,
39 | User.renew != False,
40 | User.square_id == None,
41 | User.role != None,
42 | ).all()
43 | six = datetime.today() + timedelta(hours=144)
44 | four = datetime.today() + timedelta(hours=96)
45 | with scheduler.app.app_context():
46 | first_reminder = User.query.filter(
47 | User.expiration < six,
48 | User.expiration > four,
49 | User.renew != False,
50 | User.square_id == None,
51 | User.role != None,
52 | ).all()
53 | reminder_set = set(last_reminder).union(set(first_reminder))
54 | send_reminder_emails(scheduler.app, reminder_set)
55 | scheduler.app.logger.info('Finished BTCPay renewals')
56 |
57 |
58 | def renewals_square(begin):
59 | scheduler.app.logger.info('Starting Square renewals')
60 | tomorrow = datetime.today() + timedelta(hours=24)
61 | failed_list = []
62 | declined_list = []
63 | with scheduler.app.app_context():
64 | charge_list = User.query.filter(
65 | User.expiration < tomorrow,
66 | User.expiration > begin,
67 | User.square_id != None,
68 | User.role != None,
69 | ).all()
70 | if charge_list:
71 | square = Square.query.first()
72 | api_client = ApiClient()
73 | api_client.configuration.access_token = square.access_token
74 | transactions_api = TransactionsApi(api_client)
75 | for user in charge_list:
76 | idempotency_key = str(uuid.uuid1())
77 | price_level = PriceLevel.query.filter_by(
78 | name=user.role).first()
79 | if price_level is None:
80 | failed_list.append(user)
81 | continue
82 | cents = price_level.price * 100
83 | amount = {'amount': cents, 'currency': 'USD'}
84 | body = {
85 | 'idempotency_key': idempotency_key,
86 | 'customer_id': user.square_id,
87 | 'customer_card_id': user.square_card,
88 | 'amount_money': amount,
89 | }
90 | try:
91 | charge_response = transactions_api.charge(
92 | square.location_id, body
93 | )
94 | except Exception as e:
95 | scheduler.app.logger.info(
96 | f'{user.username} card declined {e}'
97 | )
98 | declined_list.append(user)
99 | continue
100 | transaction = charge_response.transaction
101 | if transaction is None:
102 | scheduler.app.logger.info(
103 | f'{user.username} card declined'
104 | )
105 | declined_list.append(user)
106 | continue
107 | elif transaction.id is None:
108 | scheduler.app.logger.info(
109 | f'{user.username} card declined'
110 | )
111 | declined_list.append(user)
112 | continue
113 | else:
114 | if user.expiration <= datetime.today():
115 | base = datetime.today()
116 | else:
117 | base = user.expiration
118 | user.expiration = base + timedelta(days=30)
119 | db.session.commit()
120 | send_failed_emails(
121 | scheduler.app,
122 | failed_list=failed_list,
123 | declined_list=declined_list,
124 | )
125 | scheduler.app.logger.info('Square renewals complete')
126 |
--------------------------------------------------------------------------------
/app/models.py:
--------------------------------------------------------------------------------
1 | from app import db, login, blog_engine
2 | from flask import current_app
3 | from flask_ezmail.mail import Mail
4 | from flask_login import UserMixin, current_user
5 | from flask_principal import identity_loaded, RoleNeed
6 | import jwt
7 | from time import time
8 | from werkzeug.security import generate_password_hash, check_password_hash
9 |
10 |
11 | class Email(Mail, db.Model):
12 | # SMTP object
13 | __table_args__ = {'extend_existing': True}
14 | id = db.Column(db.Integer, primary_key=True)
15 | server = db.Column(db.String(128))
16 | port = db.Column(db.Integer)
17 | username = db.Column(db.String(128))
18 | password = db.Column(db.String(128))
19 | default_sender = db.Column(db.String(128))
20 | outgoing_email = db.Column(db.String(128))
21 | use_tls = db.Column(db.Boolean)
22 | use_ssl = db.Column(db.Boolean)
23 | debug = db.Column(db.Boolean, default=False)
24 | max_emails = db.Column(db.Integer)
25 | suppress = db.Column(db.Boolean)
26 |
27 | def __repr__(self):
28 | return f'''
29 | Email Object. Server: {self.server}
30 | '''
31 |
32 |
33 | class Square(db.Model):
34 | # object with Square attributes
35 | __table_args__ = {'extend_existing': True}
36 | id = db.Column(db.Integer, primary_key=True)
37 | application_id = db.Column(db.String(128))
38 | access_token = db.Column(db.String(200))
39 | location_id = db.Column(db.String(128))
40 |
41 | def __repr__(self):
42 | return f'''
43 | Square App ID: {self.application_id} \n
44 | Square Location ID: {self.location_id}
45 | '''
46 |
47 |
48 | class PriceLevel(db.Model):
49 | # price level object
50 | __table_args__ = {'extend_existing': True}
51 | id = db.Column(db.Integer, primary_key=True)
52 | name = db.Column(db.String(64), index=True, unique=True)
53 | price = db.Column(db.Integer, index=True, unique=True)
54 | description = db.Column(db.Text)
55 |
56 |
57 | class BTCPayClientStore(db.Model):
58 | # object for storing pickled BTCPay API client
59 | __table_args__ = {'extend_existing': True}
60 | id = db.Column(db.Integer, primary_key=True)
61 | client = db.Column(db.PickleType)
62 |
63 | def __repr__(self):
64 | return f'Pickled BTCPay Client, Id {self.id}'
65 |
66 |
67 | class ThirdPartyServices(db.Model):
68 | # model for storing codes for random third party svcs
69 | __table_args__ = {'extend_existing': True}
70 | id = db.Column(db.Integer, primary_key=True)
71 | name = db.Column(db.String(64))
72 | code = db.Column(db.String(128))
73 |
74 | def __repr__(self):
75 | return f'''
76 | Third Party Service {self.id}: {self.name}
77 | {self.code}
78 | '''
79 |
80 |
81 | class User(UserMixin, db.Model):
82 | # user object
83 | __table_args__ = {'extend_existing': True}
84 | id = db.Column(db.Integer, primary_key=True)
85 | username = db.Column(db.String(64), index=True, unique=True)
86 | email = db.Column(db.String(120), index=True, unique=True)
87 | password_hash = db.Column(db.String(128))
88 | expiration = db.Column(db.DateTime, index=True)
89 | renew = db.Column(db.Boolean, index=True)
90 | mail_opt_out = db.Column(db.Boolean, index=True)
91 | role = db.Column(db.String(64))
92 | last_payment = db.Column(db.String(128))
93 | square_id = db.Column(db.String(120), index=True)
94 | square_card = db.Column(db.String(120), index=True)
95 |
96 | def __repr__(self):
97 | return f''
98 |
99 | def __str__(self):
100 | expire_date = self.expiration.date()
101 | return f'''
102 | {self.id},
103 | {self.username},
104 | {self.email},
105 | {expire_date},
106 | {self.role},
107 | {self.mail_opt_out}
108 | '''
109 |
110 | def set_password(self, password):
111 | self.password_hash = generate_password_hash(password)
112 |
113 | def check_password(self, password):
114 | return check_password_hash(self.password_hash, password)
115 |
116 | def get_reset_password_token(self, expires_in=600):
117 | return jwt.encode(
118 | {'reset_password': self.id, 'exp': time() + expires_in},
119 | current_app.config['SECRET_KEY'],
120 | algorithm='HS256').decode('utf-8')
121 |
122 | @staticmethod
123 | def verify_reset_password_token(token):
124 | try:
125 | id = jwt.decode(
126 | token, current_app.config['SECRET_KEY'],
127 | algorithms=['HS256'])['reset_password']
128 | except:
129 | return
130 | return User.query.get(id)
131 |
132 |
133 | @login.user_loader
134 | @blog_engine.user_loader
135 | def load_user(id):
136 | return User.query.get(int(id))
137 |
138 |
139 | @identity_loaded.connect
140 | def on_identity_loaded(sender, identity):
141 | if hasattr(current_user, 'role'):
142 | if current_user.role == 'admin':
143 | identity.provides.add(RoleNeed('admin'))
144 | identity.provides.add(RoleNeed('blogger'))
145 | identity.user = current_user
146 |
--------------------------------------------------------------------------------
/app/static/custom-old.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding-top: 80px;
3 | padding-left: 5%;
4 | padding-right: 5%;
5 | max-width: 900px;
6 | min-width: 300px;
7 | box-sizing: border-box;
8 | margin: 0 auto;
9 | }
10 |
11 | .comments {
12 | transform: scale(0.75);
13 | min-width: 350px;
14 | margin: 0 auto;
15 | }
16 |
17 | #isso-thread h4 {
18 | visibility: hidden;
19 | }
20 |
21 | .navbar {
22 | background-color: #245fd6;
23 | }
24 | .navbar .navbar-brand {
25 | color: #ecf0f1;
26 | }
27 | .navbar .navbar-brand:hover,
28 | .navbar .navbar-brand:focus {
29 | color: #ecf0f1;
30 | }
31 | .navbar .navbar-text {
32 | color: #ecf0f1;
33 | }
34 | .navbar .navbar-text a {
35 | color: #ecf0f1;
36 | }
37 | .navbar .navbar-text a:hover,
38 | .navbar .navbar-text a:focus {
39 | color: #ecf0f1;
40 | }
41 | .navbar .navbar-nav .nav-link {
42 | color: #ecf0f1;
43 | border-radius: .25rem;
44 | margin: 0 0.25em;
45 | }
46 | .navbar .navbar-nav .nav-link:not(.disabled):hover,
47 | .navbar .navbar-nav .nav-link:not(.disabled):focus {
48 | color: #ecf0f1;
49 | }
50 | .navbar .navbar-nav .nav-item.active .nav-link,
51 | .navbar .navbar-nav .nav-item.active .nav-link:hover,
52 | .navbar .navbar-nav .nav-item.active .nav-link:focus,
53 | .navbar .navbar-nav .nav-item.show .nav-link,
54 | .navbar .navbar-nav .nav-item.show .nav-link:hover,
55 | .navbar .navbar-nav .nav-item.show .nav-link:focus {
56 | color: #ecf0f1;
57 | background-color: #245fd6;
58 | }
59 | .navbar .navbar-toggle {
60 | border-color: #ecf0f1;
61 | }
62 | .navbar .navbar-toggle:hover,
63 | .navbar .navbar-toggle:focus {
64 | background-color: #ecf0f1;
65 | }
66 | .navbar .navbar-toggle .navbar-toggler-icon {
67 | color: #ecf0f1;
68 | }
69 | .navbar .navbar-collapse,
70 | .navbar .navbar-form {
71 | border-color: #ecf0f1;
72 | }
73 | .navbar .navbar-link {
74 | color: #ecf0f1;
75 | }
76 | .navbar .navbar-link:hover {
77 | color: #ecf0f1;
78 | }
79 |
80 | @media (max-width: 575px) {
81 | .navbar-expand-sm .navbar-nav .show .dropdown-menu .dropdown-item {
82 | color: #ecf0f1;
83 | }
84 | .navbar-expand-sm .navbar-nav .show .dropdown-menu .dropdown-item:hover,
85 | .navbar-expand-sm .navbar-nav .show .dropdown-menu .dropdown-item:focus {
86 | color: #ecf0f1;
87 | }
88 | .navbar-expand-sm .navbar-nav .show .dropdown-menu .dropdown-item.active {
89 | color: #ecf0f1;
90 | background-color: #245fd6;
91 | }
92 | }
93 |
94 | @media (max-width: 767px) {
95 | .navbar-expand-md .navbar-nav .show .dropdown-menu .dropdown-item {
96 | color: #ecf0f1;
97 | }
98 | .navbar-expand-md .navbar-nav .show .dropdown-menu .dropdown-item:hover,
99 | .navbar-expand-md .navbar-nav .show .dropdown-menu .dropdown-item:focus {
100 | color: #ecf0f1;
101 | }
102 | .navbar-expand-md .navbar-nav .show .dropdown-menu .dropdown-item.active {
103 | color: #ecf0f1;
104 | background-color: #245fd6;
105 | }
106 | }
107 |
108 | @media (max-width: 991px) {
109 | .navbar-expand-lg .navbar-nav .show .dropdown-menu .dropdown-item {
110 | color: #ecf0f1;
111 | }
112 | .navbar-expand-lg .navbar-nav .show .dropdown-menu .dropdown-item:hover,
113 | .navbar-expand-lg .navbar-nav .show .dropdown-menu .dropdown-item:focus {
114 | color: #ecf0f1;
115 | }
116 | .navbar-expand-lg .navbar-nav .show .dropdown-menu .dropdown-item.active {
117 | color: #ecf0f1;
118 | background-color: #245fd6;
119 | }
120 | }
121 |
122 | @media (max-width: 1199px) {
123 | .navbar-expand-xl .navbar-nav .show .dropdown-menu .dropdown-item {
124 | color: #ecf0f1;
125 | }
126 | .navbar-expand-xl .navbar-nav .show .dropdown-menu .dropdown-item:hover,
127 | .navbar-expand-xl .navbar-nav .show .dropdown-menu .dropdown-item:focus {
128 | color: #ecf0f1;
129 | }
130 | .navbar-expand-xl .navbar-nav .show .dropdown-menu .dropdown-item.active {
131 | color: #ecf0f1;
132 | background-color: #245fd6;
133 | }
134 | }
135 |
136 | .navbar-expand .navbar-nav .show .dropdown-menu .dropdown-item {
137 | color: #ecf0f1;
138 | }
139 | .navbar-expand .navbar-nav .show .dropdown-menu .dropdown-item:hover,
140 | .navbar-expand .navbar-nav .show .dropdown-menu .dropdown-item:focus {
141 | color: #ecf0f1;
142 | }
143 | .navbar-expand .navbar-nav .show .dropdown-menu .dropdown-item.active {
144 | color: #ecf0f1;
145 | background-color: #245fd6;
146 | }
147 |
148 |
149 | .btn-default {
150 | color: #ffffff;
151 | background-color: #245FD6;
152 | border-color: #130269;
153 | }
154 |
155 | .btn-default:hover,
156 | .btn-default:focus,
157 | .btn-default:active,
158 | .btn-default.active,
159 | .open .dropdown-toggle.btn-default {
160 | color: #ffffff;
161 | background-color: #4B68A3;
162 | border-color: #130269;
163 | }
164 |
165 | .btn-default:active,
166 | .btn-default.active,
167 | .open .dropdown-toggle.btn-default {
168 | background-image: none;
169 | }
170 |
171 | .btn-default.disabled,
172 | .btn-default[disabled],
173 | fieldset[disabled] .btn-default,
174 | .btn-default.disabled:hover,
175 | .btn-default[disabled]:hover,
176 | fieldset[disabled] .btn-default:hover,
177 | .btn-default.disabled:focus,
178 | .btn-default[disabled]:focus,
179 | fieldset[disabled] .btn-default:focus,
180 | .btn-default.disabled:active,
181 | .btn-default[disabled]:active,
182 | fieldset[disabled] .btn-default:active,
183 | .btn-default.disabled.active,
184 | .btn-default[disabled].active,
185 | fieldset[disabled] .btn-default.active {
186 | background-color: #245FD6;
187 | border-color: #130269;
188 | }
189 |
190 | .btn-default .badge {
191 | color: #245FD6;
192 | background-color: #ffffff;
193 | }
194 |
--------------------------------------------------------------------------------
/app/static/sqpaymentform-basic.js:
--------------------------------------------------------------------------------
1 | var sq = document.getElementById("sq");
2 | var applicationId = sq.dataset.appid;
3 | var locationId = sq.dataset.locid;
4 |
5 | document.addEventListener("DOMContentLoaded", function(event) {
6 | if (SqPaymentForm.isSupportedBrowser()) {
7 | paymentForm.build();
8 | paymentForm.recalculateSize();
9 | }
10 | });
11 |
12 | function buildForm(form) {
13 | if (SqPaymentForm.isSupportedBrowser()) {
14 | form.build();
15 | form.recalculateSize();
16 | }
17 | }
18 |
19 | /*
20 | * function: requestCardNonce
21 | *
22 | * requestCardNonce is triggered when the "Pay with credit card" button is
23 | * clicked
24 | *
25 | * Modifying this function is not required, but can be customized if you
26 | * wish to take additional action when the form button is clicked.
27 | */
28 | function requestCardNonce(event) {
29 |
30 | // Don't submit the form until SqPaymentForm returns with a nonce
31 | event.preventDefault();
32 |
33 | // Request a nonce from the SqPaymentForm object
34 | paymentForm.requestCardNonce();
35 | }
36 |
37 | // Create and initialize a payment form object
38 | var paymentForm = new SqPaymentForm({
39 |
40 | // Initialize the payment form elements
41 | applicationId: applicationId,
42 | locationId: locationId,
43 | inputClass: 'sq-input',
44 | autoBuild: false,
45 |
46 | // Customize the CSS for SqPaymentForm iframe elements
47 | inputStyles: [{
48 | fontSize: '16px',
49 | fontFamily: 'Helvetica Neue',
50 | padding: '16px',
51 | color: '#373F4A',
52 | backgroundColor: 'transparent',
53 | lineHeight: '24px',
54 | placeholderColor: '#CCC',
55 | _webkitFontSmoothing: 'antialiased',
56 | _mozOsxFontSmoothing: 'grayscale'
57 | }],
58 |
59 | // Initialize Apple Pay placeholder ID
60 | applePay: false,
61 |
62 | // Initialize Masterpass placeholder ID
63 | masterpass: false,
64 |
65 | // Initialize the credit card placeholders
66 | cardNumber: {
67 | elementId: 'sq-card-number',
68 | placeholder: '• • • • • • • • • • • • • • • •'
69 | },
70 | cvv: {
71 | elementId: 'sq-cvv',
72 | placeholder: 'CVV'
73 | },
74 | expirationDate: {
75 | elementId: 'sq-expiration-date',
76 | placeholder: 'MM/YY'
77 | },
78 | postalCode: {
79 | elementId: 'sq-postal-code',
80 | placeholder: '12345'
81 | },
82 |
83 | // SqPaymentForm callback functions
84 | callbacks: {
85 | /*
86 | * callback function: createPaymentRequest
87 | * Triggered when: a digital wallet payment button is clicked.
88 | * Replace the JSON object declaration with a function that creates
89 | * a JSON object with Digital Wallet payment details
90 | */
91 | createPaymentRequest: function () {
92 |
93 | return {
94 | requestShippingAddress: false,
95 | requestBillingInfo: true,
96 | currencyCode: "USD",
97 | countryCode: "US",
98 | total: {
99 | label: "MERCHANT NAME",
100 | amount: "100",
101 | pending: false
102 | },
103 | lineItems: [
104 | {
105 | label: "Subtotal",
106 | amount: "100",
107 | pending: false
108 | }
109 | ]
110 | }
111 | },
112 |
113 | /*
114 | * callback function: cardNonceResponseReceived
115 | * Triggered when: SqPaymentForm completes a card nonce request
116 | */
117 | cardNonceResponseReceived: function (errors, nonce, cardData) {
118 | if (errors) {
119 | // Log errors from nonce generation to the Javascript console
120 | console.log("Encountered errors:");
121 | errors.forEach(function (error) {
122 | console.log(' ' + error.message);
123 | alert(error.message);
124 | });
125 |
126 | return;
127 | }
128 | // Assign the nonce value to the hidden form field
129 | document.getElementById('card-nonce').value = nonce;
130 |
131 | // POST the nonce form to the payment processing page
132 | document.getElementById('nonce-form').submit();
133 |
134 | },
135 |
136 | /*
137 | * callback function: unsupportedBrowserDetected
138 | * Triggered when: the page loads and an unsupported browser is detected
139 | */
140 | unsupportedBrowserDetected: function () {
141 | /* PROVIDE FEEDBACK TO SITE VISITORS */
142 | },
143 |
144 | /*
145 | * callback function: inputEventReceived
146 | * Triggered when: visitors interact with SqPaymentForm iframe elements.
147 | */
148 | inputEventReceived: function (inputEvent) {
149 | switch (inputEvent.eventType) {
150 | case 'focusClassAdded':
151 | /* HANDLE AS DESIRED */
152 | break;
153 | case 'focusClassRemoved':
154 | /* HANDLE AS DESIRED */
155 | break;
156 | case 'errorClassAdded':
157 | document.getElementById("error").innerHTML = "Please fix card information errors before continuing.";
158 | break;
159 | case 'errorClassRemoved':
160 | /* HANDLE AS DESIRED */
161 | document.getElementById("error").style.display = "none";
162 | break;
163 | case 'cardBrandChanged':
164 | /* HANDLE AS DESIRED */
165 | break;
166 | case 'postalCodeChanged':
167 | /* HANDLE AS DESIRED */
168 | break;
169 | }
170 | },
171 |
172 | /*
173 | * callback function: paymentFormLoaded
174 | * Triggered when: SqPaymentForm is fully loaded
175 | */
176 | paymentFormLoaded: function () {
177 | /* HANDLE AS DESIRED */
178 | console.log("The form loaded!");
179 | }
180 | }
181 | });
182 |
--------------------------------------------------------------------------------
/app/__init__.py:
--------------------------------------------------------------------------------
1 | from config import Config
2 | from configparser import ConfigParser
3 | from copy import deepcopy
4 | from datetime import datetime, timedelta
5 | from flask import Flask, redirect, url_for
6 | from flask_admin import Admin, AdminIndexView, expose
7 | from flask_apscheduler import APScheduler
8 | from flask_blogging_patron import BloggingEngine, SQLAStorage
9 | from flask_bootstrap import Bootstrap
10 | from flask_login import LoginManager, current_user
11 | from flask_migrate import Migrate
12 | from flask_principal import Permission, RoleNeed
13 | from flask_sqlalchemy import SQLAlchemy
14 | import os
15 | import shelve
16 |
17 | '''
18 | Unfortunately Flask App factories can't conform to PEP
19 | Codex beauty standards!
20 | '''
21 |
22 |
23 | VERSION = '0.7.37'
24 |
25 | # register extensions
26 | bootstrap = Bootstrap()
27 | db = SQLAlchemy()
28 | migrate = Migrate()
29 | global sql_storage
30 | blog_engine = BloggingEngine()
31 | login = LoginManager()
32 | login.login_view = 'auth.login'
33 | login.login_message_category = 'info'
34 | scheduler = APScheduler()
35 |
36 |
37 | # register Flask-Admin
38 | class AdminHomeView(AdminIndexView):
39 | @expose('/')
40 | def index(self):
41 | return self.render('admin/index.html', version=VERSION)
42 |
43 | def is_accessible(self):
44 | return current_user.is_authenticated and \
45 | current_user.role == 'admin'
46 |
47 | def inaccessible_callback(self, name, **kwargs):
48 | return redirect(url_for('auth.login'))
49 |
50 |
51 | admin = Admin(
52 | name='LibrePatron Admin',
53 | template_mode='bootstrap3',
54 | index_view=AdminHomeView(),
55 | )
56 |
57 |
58 | # this will be needed in the create_app fn later
59 | global temp_bp
60 |
61 | # permissions - flask_principal objects created by BloggingEngine
62 | principals = blog_engine.principal
63 | admin_permission = Permission(RoleNeed('admin'))
64 |
65 |
66 | def create_app(config_class=Config):
67 | app = Flask(__name__)
68 | app.config.from_object(config_class)
69 | app.jinja_env.globals['THEME_FILE'] = 'themes/' + \
70 | app.config['THEME'] + '.min.css'
71 | # check for Isso config file. If none exists, make a fake one.
72 | # The isso container always needs a config file to read, even if garbage.
73 | file = app.config['ISSO_CONFIG_PATH']
74 | if not os.path.isfile(file):
75 | isso_config = ConfigParser()
76 | isso_config['default'] = {}
77 | isso_config['default']['dbpath'] = \
78 | 'var/lib/db/comments.db'
79 | isso_config['default']['host'] = \
80 | 'http://localhost:5000/'
81 | with open(file, 'w') as configfile:
82 | isso_config.write(configfile)
83 | app.logger.info('Isso dummy configuration success.')
84 | bootstrap.init_app(app)
85 | db.init_app(app)
86 | with app.app_context():
87 | global sql_storage
88 | sql_storage = SQLAStorage(db=db)
89 | migrate.init_app(app, db)
90 | login.init_app(app)
91 | admin.init_app(app)
92 | blog_engine.init_app(app, sql_storage)
93 | global SCHEDULER_HOUR
94 | global SCHEDULER_MINUTE
95 | SCHEDULER_HOUR = app.config.get('SCHEDULER_HOUR')
96 | SCHEDULER_MINUTE = app.config.get('SCHEDULER_MINUTE')
97 | scheduler.init_app(app)
98 | scheduler.start()
99 |
100 | # deepcopy auto-generated flask_blogging bp, then delete it
101 | global temp_bp
102 | temp_bp = deepcopy(app.blueprints['blogging'])
103 | del app.blueprints['blogging']
104 |
105 | # blueprints
106 | from app.admin_utils import bp as admin_utils_bp
107 | from app.api import bp as api_bp
108 | from app.auth import bp as auth_bp
109 | from app.blogging import bp as blogging_bp
110 | from app.main import bp as main_bp
111 | app.register_blueprint(admin_utils_bp, url_prefix='/admin_utils')
112 | app.register_blueprint(auth_bp, url_prefix='/auth')
113 | app.register_blueprint(api_bp, url_prefix='/api')
114 | app.register_blueprint(
115 | blogging_bp,
116 | url_prefix=app.config.get('BLOGGING_URL_PREFIX')
117 | )
118 | app.register_blueprint(main_bp)
119 |
120 | import logging
121 | from logging import StreamHandler
122 | stream_handler = StreamHandler()
123 | stream_handler.setLevel(logging.INFO)
124 | app.logger.addHandler(stream_handler)
125 | app.logger.setLevel(logging.INFO)
126 | with shelve.open(app.config['SECRET_KEY_LOCATION']) as storage:
127 | if storage.get('last_renewal') is None:
128 | delta = datetime.today().hour - SCHEDULER_HOUR + 24
129 | storage['last_renewal'] = datetime.today() - timedelta(hours=delta)
130 | app.logger.info('Dummy last renewal date created.')
131 |
132 | # pre-first request loads
133 | @app.before_first_request
134 | def load_ga():
135 | from app.models import ThirdPartyServices
136 | ga = ThirdPartyServices.query.filter_by(name='ga').first()
137 | if ga is not None:
138 | app.config['BLOGGING_GOOGLE_ANALYTICS'] = ga.code
139 | app.logger.info('GA configuration success.')
140 |
141 | @app.before_first_request
142 | def load_theme():
143 | from app.models import ThirdPartyServices
144 | theme = ThirdPartyServices.query.filter_by(name='theme').first()
145 | if theme is not None:
146 | app.config['THEME'] = theme.code
147 | app.jinja_env.globals['THEME_FILE'] = 'themes/' + \
148 | theme.code + '.min.css'
149 | app.logger.info('Theme configuration success.')
150 |
151 | @app.before_first_request
152 | def load_tasks():
153 | from app import tasks # noqa: F401
154 | app.logger.info(f'Next renewal time: \
155 | {scheduler._scheduler.get_jobs()[0].next_run_time}')
156 |
157 | return app
158 |
159 |
160 | from app import admin_views # noqa: F401
161 | from app import models, subscriptions # noqa: F401
162 |
--------------------------------------------------------------------------------
/flask_blogging_patron/engine.py:
--------------------------------------------------------------------------------
1 | """
2 | The BloggingEngine module.
3 | """
4 | try:
5 | from builtins import object
6 | except ImportError:
7 | pass
8 | from .processor import PostProcessor
9 | from flask_principal import Principal, Permission, RoleNeed
10 | from .signals import engine_initialised, post_processed, blueprint_created
11 | from flask_fileupload import FlaskFileUpload
12 |
13 |
14 | class BloggingEngine(object):
15 | """
16 | The BloggingEngine is the class for initializing the blog support for your
17 | web app. Here is an example usage:
18 |
19 | .. code:: python
20 |
21 | from flask import Flask
22 | from flask_blogging import BloggingEngine, SQLAStorage
23 | from sqlalchemy import create_engine
24 |
25 | app = Flask(__name__)
26 | db_engine = create_engine("sqlite:////tmp/sqlite.db")
27 | meta = MetaData()
28 | storage = SQLAStorage(db_engine, metadata=meta)
29 | blog_engine = BloggingEngine(app, storage)
30 | """
31 | def __init__(self, app=None, storage=None, post_processor=None,
32 | extensions=None, cache=None, file_upload=None):
33 | """
34 |
35 | :param app: Optional app to use
36 | :type app: object
37 | :param storage: The blog storage instance that implements the
38 | ``Storage`` class interface.
39 | :type storage: object
40 | :param post_processor: (optional) The post processor object. If none
41 | provided, the default post processor is used.
42 | :type post_processor: object
43 | :param extensions: (optional) A list of markdown extensions to add to
44 | post processing step.
45 | :type extensions: list
46 | :param cache: (Optional) A Flask-Cache object to enable caching
47 | :type cache: Object
48 | :param file_upload: (Optional) A FileUpload object from
49 | flask_fileupload extension
50 | :type file_upload: Object
51 | :return:
52 | """
53 | self.app = None
54 | self.storage = storage
55 | self.config = None
56 | self.ffu = None
57 | self.cache = cache
58 | self._blogger_permission = None
59 | self.post_processor = PostProcessor() if post_processor is None \
60 | else post_processor
61 | if extensions:
62 | self.post_processor.set_custom_extensions(extensions)
63 | self.user_callback = None
64 | self.file_upload = file_upload
65 | if app is not None and storage is not None:
66 | self.init_app(app, storage)
67 | self.principal = None
68 |
69 | @classmethod
70 | def _register_plugins(cls, app, config):
71 | plugins = config.get("BLOGGING_PLUGINS")
72 | if plugins:
73 | for plugin in plugins:
74 | lib = __import__(plugin, globals(), locals(), str("module"))
75 | lib.register(app)
76 |
77 | def init_app(self, app, storage=None, cache=None):
78 | """
79 | Initialize the engine.
80 |
81 | :param app: The app to use
82 | :type app: Object
83 | :param storage: The blog storage instance that implements the
84 | :type storage: Object
85 | :param cache: (Optional) A Flask-Cache object to enable caching
86 | :type cache: Object
87 | ``Storage`` class interface.
88 | """
89 |
90 | self.app = app
91 | self.config = self.app.config
92 | self.storage = storage or self.storage
93 | self.cache = cache or self.cache
94 | self._register_plugins(self.app, self.config)
95 |
96 | from .views import create_blueprint
97 | blog_app = create_blueprint(__name__, self)
98 | # external urls
99 | blueprint_created.send(self.app, engine=self, blueprint=blog_app)
100 | self.app.register_blueprint(
101 | blog_app, url_prefix=self.config.get("BLOGGING_URL_PREFIX"))
102 |
103 | self.app.extensions["FLASK_BLOGGING_ENGINE"] = self # duplicate
104 | self.app.extensions["blogging"] = self
105 | self.principal = Principal(self.app)
106 | engine_initialised.send(self.app, engine=self)
107 |
108 | if self.config.get("BLOGGING_ALLOW_FILEUPLOAD", True):
109 | self.ffu = self.file_upload or FlaskFileUpload(app)
110 |
111 | @property
112 | def blogger_permission(self):
113 | if self._blogger_permission is None:
114 | if self.config.get("BLOGGING_PERMISSIONS", False):
115 | self._blogger_permission = Permission(RoleNeed(
116 | self.config.get("BLOGGING_PERMISSIONNAME", "blogger")))
117 | else:
118 | self._blogger_permission = Permission()
119 | return self._blogger_permission
120 |
121 | def user_loader(self, callback):
122 | """
123 | The decorator for loading the user.
124 |
125 | :param callback: The callback function that can load a user given a
126 | unicode ``user_id``.
127 | :return: The callback function
128 | """
129 | self.user_callback = callback
130 | return callback
131 |
132 | def is_user_blogger(self):
133 | return self.blogger_permission.require().can()
134 |
135 | def get_posts(self, count=10, offset=0, recent=True, tag=None,
136 | user_id=None, include_draft=False, render=False):
137 | posts = self.storage(count, offset, recent, tag, user_id,
138 | include_draft)
139 | for post in posts:
140 | self.process_post(post, render=False)
141 |
142 | def process_post(self, post, render=True):
143 | """
144 | A high level view to create post processing.
145 | :param post: Dictionary representing the post
146 | :type post: dict
147 | :param render: Choice if the markdown text has to be converted or not
148 | :type render: bool
149 | :return:
150 | """
151 | post_processor = self.post_processor
152 | post_processor.process(post, render)
153 | try:
154 | author = self.user_callback(post["user_id"])
155 | except Exception:
156 | raise Exception("No user_loader has been installed for this "
157 | "BloggingEngine. Add one with the "
158 | "'BloggingEngine.user_loader' decorator.")
159 | if author is not None:
160 | post["user_name"] = self.get_user_name(author)
161 | post_processed.send(self.app, engine=self, post=post, render=render)
162 |
163 | @classmethod
164 | def get_user_name(cls, user):
165 | user_name = user.get_name() if hasattr(user, "get_name") else str(user)
166 | return user_name
167 |
--------------------------------------------------------------------------------
/app/email.py:
--------------------------------------------------------------------------------
1 | from app.models import User, Email
2 | from datetime import datetime
3 | from flask import render_template, current_app, url_for
4 | from flask_ezmail.message import Message
5 | import logging
6 | from markdown import Markdown
7 | from threading import Thread
8 | from urllib.parse import urlencode
9 |
10 |
11 | def send_async_email(app, msg):
12 | # sends a single email asyncronously
13 | with app.app_context():
14 | mail = Email.query.first()
15 | mail.send(msg)
16 |
17 |
18 | def send_async_bulkmail(app, msg, users):
19 | # accepts user list and message, sending msg to all paid users
20 | with app.app_context():
21 | mail = Email.query.first()
22 | try:
23 | with mail.connect() as conn:
24 | for user in users:
25 | if user.expiration <= datetime.today():
26 | break
27 | msg.recipients = [user.email]
28 | conn.send(msg)
29 | except Exception:
30 | logging.exception('Exception in send_async_bulkmail')
31 | raise
32 |
33 |
34 | def send_email(subject, sender, recipients, text_body, html_body):
35 | # composes a single email and passes it to send_async_email fn
36 | msg = Message(subject, sender=sender, recipients=recipients)
37 | msg.body = text_body
38 | msg.html = html_body
39 | Thread(
40 | target=send_async_email,
41 | args=(current_app._get_current_object(), msg)).start()
42 |
43 |
44 | def send_reminder_emails(app, reminder_set):
45 | '''
46 | Takes a list of users about to expire, and emails them fresh
47 | payment links that direct to BTCPay.
48 | '''
49 | if not reminder_set:
50 | return None
51 | with app.app_context():
52 | mail = Email.query.first()
53 | try:
54 | site = app.config['BLOGGING_SITENAME']
55 | with mail.connect() as conn:
56 | for user in reminder_set:
57 | dict = {}
58 | dict['username'] = user.username
59 | params = urlencode(dict)
60 | url = str(url_for('main.create_invoice'))\
61 | + '?' + str(params)
62 | expires = user.expiration.date()
63 | msg = Message(
64 | f'{site} Renewal',
65 | sender=mail.default_sender,
66 | recipients=[user.email],
67 | body=render_template(
68 | 'email/reminder.txt',
69 | site=site,
70 | user=user,
71 | url=url,
72 | expires=expires,
73 | ),
74 | html=None
75 | )
76 | conn.send(msg)
77 | except Exception:
78 | logging.exception('Exception in send_reminder_emails')
79 | raise
80 |
81 |
82 | def send_failed_emails(app, failed_list, declined_list):
83 | '''
84 | Takes a list of users whose credit card renewals failed via
85 | Square, and emails them asking to update their credit card.
86 | '''
87 | if not failed_list and not declined_list:
88 | return None
89 | with app.app_context():
90 | mail = Email.query.first()
91 | site = app.config['BLOGGING_SITENAME']
92 | url = url_for('main.support')
93 | with mail.connect() as conn:
94 | for user in failed_list:
95 | expires = user.expiration.date()
96 | msg = Message(
97 | f'{site} Subscription Update',
98 | sender=mail.default_sender,
99 | recipients=[user.email],
100 | body=render_template(
101 | 'email/reminder_cc.txt',
102 | site=site,
103 | user=user,
104 | url=url,
105 | expires=expires,
106 | ),
107 | html=None
108 | )
109 | conn.send(msg)
110 | for user in declined_list:
111 | expires = user.expiration.date()
112 | msg = Message(
113 | f'{site} Card Declined',
114 | sender=mail.default_sender,
115 | recipients=[user.email],
116 | body=render_template(
117 | 'email/cc_declined.txt',
118 | site=site,
119 | user=user,
120 | url=url,
121 | expires=expires,
122 | ),
123 | html=None
124 | )
125 | conn.send(msg)
126 |
127 |
128 | def send_bulkmail(subject, sender, users, text_body, html_body):
129 | # composes an email message and sends ti to send_async_bulkmail
130 | msg = Message(subject, sender=sender)
131 | msg.body = text_body
132 | msg.html = html_body
133 | Thread(
134 | target=send_async_bulkmail,
135 | args=(current_app._get_current_object(), msg, users)).start()
136 |
137 |
138 | def send_password_reset_email(user):
139 | # emails user a token to reset password
140 | token = user.get_reset_password_token()
141 | mail = Email.query.first()
142 | send_email(
143 | 'Password Reset',
144 | sender=mail.default_sender,
145 | recipients=[user.email],
146 | text_body=render_template('email/reset_password.txt',
147 | user=user, token=token),
148 | html_body=None
149 | )
150 |
151 |
152 | def email_post(post):
153 | '''
154 | Takes a blog post, uses the Markdown engine to render it to HTML,
155 | then creates an email message from the HTML. The msg is then passed
156 | to send_bulkmail function.
157 | '''
158 | mail = Email.query.first()
159 | try:
160 | markdown = Markdown()
161 | post['rendered_text'] = markdown.convert(post['text'])
162 | html_body = render_template(
163 | 'email/email_post.html',
164 | post=post,
165 | )
166 | text_body = render_template(
167 | 'email/email_post.txt',
168 | post=post,
169 | )
170 | site = current_app.config.get('BLOGGING_SITENAME')
171 | users = User.query.filter_by(mail_opt_out=False).all()
172 | send_bulkmail(
173 | f'New Update from {site}',
174 | sender=mail.default_sender,
175 | users=users,
176 | html_body=html_body,
177 | text_body=text_body
178 | )
179 | except Exception:
180 | logging.exception('Exception in email_post')
181 | raise
182 |
--------------------------------------------------------------------------------
/app/auth/routes.py:
--------------------------------------------------------------------------------
1 | from app import db
2 | from app.auth import bp
3 | from app.auth.forms import LoginForm, RegistrationForm, AdminForm,\
4 | ResetPasswordForm, ResetPasswordRequestForm
5 | from app.email import send_password_reset_email
6 | from app.models import User
7 | from app.utils import is_safe_url
8 | from flask import redirect, url_for, render_template, flash, current_app,\
9 | request, abort
10 | from flask_login import current_user, login_user, logout_user, login_required
11 | from flask_principal import Identity, identity_changed
12 | from datetime import date, timedelta
13 |
14 |
15 | @bp.route('/login', methods=['GET', 'POST'])
16 | def login():
17 | # login page
18 | if current_user.is_authenticated:
19 | return redirect(url_for('auth.account'))
20 | form = LoginForm()
21 | if form.validate_on_submit():
22 | user = User.query.filter_by(username=form.username.data).first()
23 | if user is None or not user.check_password(form.password.data):
24 | flash('Invalid username or password', 'warning')
25 | return redirect(url_for('auth.login'))
26 | flash('Successful login.', 'info')
27 | login_user(user, remember=form.remember_me.data)
28 | next = request.args.get('next')
29 | if not is_safe_url(next):
30 | return abort(400)
31 | if user.role == 'admin':
32 | identity_changed.send(
33 | current_app._get_current_object(),
34 | identity=Identity(user.id)
35 | )
36 | return redirect(next or url_for('main.index'))
37 | return render_template('auth/login.html', title='Sign In', form=form)
38 |
39 |
40 | @bp.route('/logout')
41 | @login_required
42 | def logout():
43 | # logs user out
44 | logout_user()
45 | flash('You are logged out.', 'info')
46 | return redirect(url_for('main.index'))
47 |
48 |
49 | @bp.route('/register', methods=['GET', 'POST'])
50 | def register():
51 | # registers a new user
52 | if current_user.is_authenticated:
53 | flash('You are already registered.')
54 | return redirect(url_for('main.index'))
55 | elif User.query.filter_by(role='admin').first() is None:
56 | return redirect(url_for('auth.adminsetup'))
57 | form = RegistrationForm()
58 | if form.validate_on_submit():
59 | expiration = date.today() - timedelta(days=1)
60 | user = User(
61 | username=form.username.data,
62 | email=form.email.data,
63 | expiration=expiration,
64 | mail_opt_out=False
65 | )
66 | user.set_password(form.password.data)
67 | db.session.add(user)
68 | db.session.commit()
69 | flash('You are now a registered user.', 'info')
70 | return redirect(url_for('auth.login'))
71 | return render_template('auth/register.html', title='Register', form=form)
72 |
73 |
74 | @bp.route('/adminsetup', methods=['GET', 'POST'])
75 | def adminsetup():
76 | # registers an admin user
77 | if User.query.filter_by(role='admin').first() is not None:
78 | flash('Administrator is already set.')
79 | return redirect(url_for('main.index'))
80 | form = AdminForm()
81 | if form.validate_on_submit():
82 | user = User(
83 | username=form.username.data,
84 | email=form.email.data,
85 | expiration=date.max,
86 | role='admin',
87 | mail_opt_out=False
88 | )
89 | user.set_password(form.password.data)
90 | db.session.add(user)
91 | db.session.commit()
92 | flash('You are now registered as the admin.', 'info')
93 | return redirect(url_for('auth.login'))
94 | return render_template(
95 | 'auth/adminsetup.html',
96 | title='Register as Administrator',
97 | form=form
98 | )
99 |
100 |
101 | @bp.route('/account')
102 | @login_required
103 | def account():
104 | # displays user's account expiration and status
105 | if hasattr(current_user, 'role'):
106 | if current_user.role == 'admin':
107 | return redirect(url_for('admin.index'))
108 | if current_user.mail_opt_out is not False:
109 | opt_out = True
110 | else:
111 | opt_out = False
112 | if current_user.square_id is not None:
113 | square = True
114 | else:
115 | square = False
116 | if current_user.expiration.date() < date.today():
117 | expires = 'No Current Subscription'
118 | else:
119 | expires = current_user.expiration.date()
120 | return render_template(
121 | 'auth/account.html',
122 | opt_out=opt_out,
123 | expires=expires,
124 | square=square,
125 | )
126 |
127 |
128 | @bp.route('/cancelcc')
129 | @login_required
130 | def cancel_square():
131 | # allows user to cancel credit card auto-billing
132 | if hasattr(current_user, 'role'):
133 | if current_user.role == 'admin':
134 | return redirect(url_for('admin.index'))
135 | if current_user.square_id is not None:
136 | current_user.square_id = None
137 | current_user.square_card = None
138 | db.session.commit()
139 | flash('Succesfully canceled credit card billing.', 'info')
140 | return redirect(url_for('auth.account'))
141 |
142 |
143 | @bp.route('/mailopt')
144 | @login_required
145 | def mail_opt():
146 | # opts the user out of email notifications for new updates
147 | if hasattr(current_user, 'role'):
148 | if current_user.role == 'admin':
149 | return redirect(url_for('admin.index'))
150 | if current_user.mail_opt_out is not False:
151 | current_user.mail_opt_out = False
152 | flash('Succesfully opted in.', 'info')
153 | else:
154 | current_user.mail_opt_out = True
155 | flash('Succesfully opted out.', 'info')
156 | db.session.commit()
157 | return redirect(url_for('auth.account'))
158 |
159 |
160 | @bp.route('/resetrequest', methods=['GET', 'POST'])
161 | def reset_password_request():
162 | # request a password reset
163 | form = ResetPasswordRequestForm()
164 | if form.validate_on_submit():
165 | user = User.query.filter_by(email=form.email.data).first()
166 | if user:
167 | send_password_reset_email(user)
168 | flash(
169 | 'Check your email for reset instructions.',
170 | 'warning'
171 | )
172 | else:
173 | flash(
174 | 'No user registered under that email address.',
175 | 'warning'
176 | )
177 | return redirect(url_for('auth.login'))
178 | return render_template('auth/reset_password.html', form=form)
179 |
180 |
181 | @bp.route('/reset_password/', methods=['GET', 'POST'])
182 | def reset_password(token):
183 | # accepts incoming password reset request from emailed link
184 | if current_user.is_authenticated:
185 | flash(
186 | 'You must log out before resetting your password.',
187 | 'warning'
188 | )
189 | return redirect(url_for('main.index'))
190 | user = User.verify_reset_password_token(token)
191 | if not user:
192 | flash('Invalid reset token.', 'warning')
193 | return redirect(url_for('main.index'))
194 | form = ResetPasswordForm()
195 | if form.validate_on_submit():
196 | user.set_password(form.password.data)
197 | db.session.commit()
198 | flash('Your password has been reset.', 'info')
199 | return redirect(url_for('auth.login'))
200 | return render_template('auth/reset_password.html', form=form)
201 |
--------------------------------------------------------------------------------
/app/main/routes.py:
--------------------------------------------------------------------------------
1 | from app import blog_engine, db
2 | from app.main import bp
3 | from app.models import BTCPayClientStore, Square, PriceLevel
4 | from datetime import datetime
5 | from flask import redirect, url_for, flash, render_template, request,\
6 | current_app
7 | from flask_blogging_patron import PostProcessor
8 | from flask_blogging_patron.views import page_by_id_fetched,\
9 | page_by_id_processed
10 | from flask_login import current_user, login_required
11 |
12 |
13 | @bp.route('/')
14 | @bp.route('/index')
15 | def index():
16 | '''
17 | Displays the main homepage. The homepage text is stored in the db
18 | as a 'post' (just like an update for paid subscribers), but it has a
19 | special "PUBLIC" tag to designate that it's the homepage. This page
20 | is viewable by all visitiors.
21 | '''
22 | try:
23 | posts = blog_engine.storage.get_posts(
24 | count=1,
25 | recent=True,
26 | tag='public'
27 | )
28 | temp_post = posts[0]
29 | post = blog_engine.storage.get_post_by_id(temp_post['post_id'])
30 | except Exception as e:
31 | if hasattr(current_user, 'id'):
32 | current_app.logger.info(
33 | f'''
34 | Automatically generated non-existent homepage due to
35 | the following: {e}
36 | '''
37 | )
38 | blog_engine.storage.save_post(
39 | 'Welcome to LibrePatron!',
40 | text='Your homepage goes here.',
41 | tags=['public'],
42 | draft=False,
43 | user_id=current_user.id,
44 | post_date=datetime.today(),
45 | last_modified_date=datetime.today(),
46 | post_id=None,
47 | )
48 | return redirect(url_for('main.index'))
49 | else:
50 | return redirect(url_for('auth.register'))
51 | config = blog_engine.config
52 | meta = {}
53 | meta['is_user_blogger'] = False
54 | if current_user.is_authenticated:
55 | if hasattr(current_user, 'role'):
56 | if current_user.role == 'admin':
57 | meta['is_user_blogger'] = True
58 | meta['post_id'] = temp_post['post_id']
59 | meta['slug'] = PostProcessor.create_slug(temp_post['title'])
60 | page_by_id_fetched.send(
61 | blog_engine.app,
62 | engine=blog_engine,
63 | post=post,
64 | meta=meta
65 | )
66 | blog_engine.process_post(post, render=True)
67 | page_by_id_processed.send(
68 | blog_engine.app,
69 | engine=blog_engine,
70 | post=post,
71 | meta=meta
72 | )
73 | return render_template(
74 | 'main/homepage.html',
75 | post=post,
76 | config=config,
77 | meta=meta
78 | )
79 |
80 |
81 | @bp.route('/support')
82 | def support():
83 | # displays priving page
84 | # also sets default pricing if none exists
85 | if PriceLevel.query.all() == []:
86 | level_1 = PriceLevel(
87 | name='Patron',
88 | description="You're a patron!",
89 | price=10,
90 | )
91 | level_2 = PriceLevel(
92 | name='Cooler Patron',
93 | description="You're a cooler patron!",
94 | price=20,
95 | )
96 | level_3 = PriceLevel(
97 | name='Coolest Patron',
98 | description="You're the best!",
99 | price=60,
100 | )
101 | db.session.add(level_1)
102 | db.session.add(level_2)
103 | db.session.add(level_3)
104 | db.session.commit()
105 | square = Square.query.first()
106 | price_levels = PriceLevel.query.all()
107 | price_levels.sort(key=lambda x: x.price, reverse=False)
108 | return render_template(
109 | 'main/support.html',
110 | levels=price_levels,
111 | square=square,
112 | )
113 |
114 |
115 | @bp.route('/creditcard')
116 | @login_required
117 | def credit_card():
118 | # directs user to sqpaymentform.js
119 | price = request.args.get('price')
120 | if price is None:
121 | flash('There was an error. Try again.')
122 | return redirect(url_for('main.support'))
123 | square = Square.query.first()
124 | if square is not None:
125 | return render_template(
126 | 'main/creditcard.html',
127 | application_id=square.application_id,
128 | location_id=square.location_id,
129 | price=price,
130 | )
131 | else:
132 | return redirect(url_for('main.index'))
133 |
134 |
135 | @bp.route('/createinvoice')
136 | @login_required
137 | def create_invoice():
138 | # creates a BTCPay invoice when a user chooses a price level
139 | user_arg = request.args.get('username')
140 | if user_arg is not None:
141 | if user_arg != current_user.username:
142 | flash('You are logged in as a different user!\
143 | Please log out first.', 'warning')
144 | return redirect(url_for('main.index'))
145 | else:
146 | current_plan = current_user.role
147 | if current_plan is not None:
148 | price_level = PriceLevel.query.filter_by(
149 | name=current_plan).first()
150 | if price_level is None:
151 | return redirect(url_for('main.support'))
152 | else:
153 | plan = price_level.name
154 | price = price_level.price
155 | else:
156 | return redirect(url_for('main.support'))
157 | else:
158 | string_price = request.args.get('price')
159 | if string_price is None:
160 | return redirect(url_for('main.support'))
161 | plan = request.args.get('name')
162 | price = int(string_price)
163 | compare = PriceLevel.query.filter_by(price=price).first()
164 | if compare is None:
165 | return redirect(url_for('main.support'))
166 | elif compare.name != plan:
167 | return redirect(url_for('main.support'))
168 | btc_client_store = BTCPayClientStore.query.first()
169 | if btc_client_store is None:
170 | current_app.logger.critical(
171 | 'Attempted to create invoice without pairing BTCPay.'
172 | )
173 | flash('Payment attempt failed. Contact the administrator.')
174 | return redirect(url_for('main.index'))
175 | elif btc_client_store.client is None:
176 | current_app.logger.critical(
177 | 'Attempted to create invoice without pairing BTCPay.'
178 | )
179 | flash('Payment attempt failed. Contact the administrator.')
180 | return redirect(url_for('main.index'))
181 | else:
182 | btc_client = btc_client_store.client
183 | try:
184 | inv_data = btc_client.create_invoice({
185 | "price": price,
186 | "currency": "USD",
187 | "buyer": {
188 | "name": current_user.username,
189 | "email": current_user.email,
190 | },
191 | "orderId": plan,
192 | "fullNotifications": True,
193 | "notificationURL": url_for(
194 | 'api.update_sub',
195 | _external=True,
196 | _scheme='https'
197 | ),
198 | "redirectURL": url_for(
199 | 'main.index',
200 | _external=True,
201 | _scheme='https'
202 | )
203 | })
204 | return redirect(inv_data['url'])
205 | except Exception:
206 | current_app.logger.exception(
207 | '''
208 | BTCPay could not create invoice. Usually this
209 | means that you have not set a derivation scheme
210 | or that your nodes are still syncing.
211 | '''
212 | )
213 | flash('Payment failed. Contact the administrator.')
214 | return redirect(url_for('main.index'))
215 |
--------------------------------------------------------------------------------
/app/api/routes.py:
--------------------------------------------------------------------------------
1 | from app import db
2 | from app.api import bp
3 | from app.models import BTCPayClientStore, User, Square, PriceLevel
4 | from datetime import datetime, timedelta
5 | from flask import request, redirect, flash, url_for, current_app
6 | from flask_login import current_user, login_required
7 | from squareconnect.api_client import ApiClient
8 | from squareconnect.apis.customers_api import CustomersApi
9 | from squareconnect.apis.transactions_api import TransactionsApi
10 | from squareconnect.models.create_customer_request import \
11 | CreateCustomerRequest
12 | from squareconnect.models.create_customer_card_request import \
13 | CreateCustomerCardRequest
14 | import uuid
15 |
16 |
17 | @bp.route('/v1/updatesub', methods=['GET', 'POST'])
18 | def update_sub():
19 | # receives and processes pmt notifications from BTCPay
20 | if not request.json or 'id' not in request.json:
21 | return "Not a valid IPN.", 200
22 | btc_client_store = BTCPayClientStore.query.first()
23 | btc_client = btc_client_store.client
24 | invoice = btc_client.get_invoice(request.json['id'])
25 | if isinstance(invoice, dict):
26 | if 'status' in invoice:
27 | current_app.logger.info('IPN: ' + invoice['status'] + ' ' +
28 | invoice['id'])
29 | if invoice['status'] == "paid" or \
30 | invoice['status'] == "complete" or \
31 | invoice['status'] == "confirmed":
32 | user = User.query.filter_by(
33 | username=invoice['buyer']['name']).first()
34 | if user is None:
35 | return "Payment made for unregistered user.", 200
36 | if user.role == 'admin':
37 | return "Administrator should not make payments.", 200
38 | elif invoice['status'] == "confirmed":
39 | if user.last_payment != invoice['id']:
40 | user.last_payment = invoice['id']
41 | if user.expiration <= datetime.today():
42 | base = datetime.today()
43 | else:
44 | base = user.expiration
45 | user.expiration = base + timedelta(days=30)
46 | user.role = invoice['orderId']
47 | user.renew = True
48 | db.session.commit()
49 | return "Payment Accepted", 201
50 | else:
51 | return "Payment Already Processed", 200
52 | elif invoice['status'] == "paid":
53 | # add a few hours if expired or almost expired
54 | measure = user.expiration - timedelta(hours=6)
55 | if measure <= datetime.today():
56 | user.expiration = datetime.today()\
57 | + timedelta(hours=6)
58 | user.role = invoice['orderId']
59 | user.renew = False
60 | db.session.commit()
61 | return "Payment Accepted", 201
62 | elif invoice['status'] == "complete":
63 | # handle lightning payments
64 | if user.last_payment != invoice['id']:
65 | user.last_payment = invoice['id']
66 | if user.expiration <= datetime.today():
67 | base = datetime.today()
68 | else:
69 | base = user.expiration
70 | user.expiration = base + timedelta(days=30)
71 | user.role = invoice['orderId']
72 | user.renew = True
73 | db.session.commit()
74 | return "Payment Accepted", 201
75 | else:
76 | return "Payment Already Processed", 200
77 | else:
78 | return "IPN Received", 200
79 | else:
80 | return "Status not paid or confirmed.", 200
81 | else:
82 | return "No payment status received.", 200
83 | else:
84 | return "Invalid transaction ID.", 400
85 |
86 |
87 | @bp.route('/v1/square/', methods=['GET', 'POST'])
88 | @login_required
89 | def process_square(price):
90 | '''
91 | Receives a nonce from Square, and uses the nonce to
92 | charge the card. Upon successful charge, it updates the
93 | user's subscription and stores the Square Customer ID and
94 | Card ID for future charges.
95 | '''
96 | if not request.form or 'nonce' not in request.form:
97 | return "Bad Request", 422
98 | square = Square.query.first()
99 | nonce = request.form['nonce']
100 | api_client = ApiClient()
101 | api_client.configuration.access_token = square.access_token
102 | customers_api = CustomersApi(api_client)
103 | customer_request = CreateCustomerRequest(
104 | email_address=current_user.email)
105 | try:
106 | customer_res = customers_api.create_customer(customer_request)
107 | except Exception as e:
108 | flash('Card could not be processed.')
109 | current_app.logger.error(e, exc_info=True)
110 | return redirect(url_for('main.support'))
111 | customer = customer_res.customer
112 | if customer is None:
113 | flash('Card could not be processed.')
114 | current_app.logger.info(
115 | f'''
116 | {current_user.username} card declined:
117 | {customer_res.errors}
118 | '''
119 | )
120 | return redirect(url_for('main.support'))
121 | else:
122 | customer_card_request = CreateCustomerCardRequest(
123 | card_nonce=nonce,
124 | )
125 | try:
126 | card_res = customers_api.create_customer_card(
127 | customer.id,
128 | customer_card_request,
129 | )
130 | except Exception as e:
131 | flash('Card could not be processed.')
132 | current_app.logger.error(e, exc_info=True)
133 | return redirect(url_for('main.support'))
134 | card = card_res.card
135 | if card is None:
136 | flash('Card could not be processed.')
137 | current_app.logger.info(
138 | f'''
139 | {current_user.username} card declined:
140 | {card_res.errors}
141 | '''
142 | )
143 | return redirect(url_for('main.support'))
144 | else:
145 | current_user.square_id = customer.id
146 | current_user.square_card = card.id
147 | transactions_api = TransactionsApi(api_client)
148 | idempotency_key = str(uuid.uuid1())
149 | cents = price * 100
150 | amount = {'amount': cents, 'currency': 'USD'}
151 | body = {
152 | 'idempotency_key': idempotency_key,
153 | 'customer_id': current_user.square_id,
154 | 'customer_card_id': current_user.square_card,
155 | 'amount_money': amount,
156 | }
157 | try:
158 | charge_response = transactions_api.charge(
159 | square.location_id, body
160 | )
161 | except Exception as e:
162 | flash('Card could not be processed.')
163 | current_app.logger.error(e, exc_info=True)
164 | return redirect(url_for('main.support'))
165 | transaction = charge_response.transaction
166 | if transaction is None:
167 | flash('Card could not be processed.')
168 | current_app.logger.info(
169 | f'''
170 | {current_user.username} card declined:
171 | {charge_response.errors}
172 | '''
173 | )
174 | return redirect(url_for('main.support'))
175 | elif transaction.id is not None:
176 | flash('Subscription Updated')
177 | if current_user.expiration <= datetime.today():
178 | base = datetime.today()
179 | else:
180 | base = current_user.expiration
181 | current_user.expiration = base + timedelta(days=30)
182 | new_role = PriceLevel.query.filter_by(price=price).first()
183 | if hasattr(new_role, 'name'):
184 | current_user.role = new_role.name
185 | else:
186 | current_user.role = PriceLevel.query.first().name
187 | current_app.logger.error(f'{current_user.username} \
188 | signed up for nonexistent price level.')
189 | db.session.commit()
190 | return redirect(url_for('main.index'))
191 |
--------------------------------------------------------------------------------
/flask_blogging_patron/signals.py:
--------------------------------------------------------------------------------
1 | """
2 | The flask_blogging signals module
3 |
4 | """
5 |
6 |
7 | import blinker
8 |
9 | signals = blinker.Namespace()
10 |
11 | engine_initialised = signals.signal("engine_initialised", doc="""\
12 | Signal send by the ``BloggingEngine`` after the object is initialized.
13 | The arguments passed by the signal are:
14 |
15 | :param app: The Flask app which is the sender
16 | :type app: object
17 | :keyword engine: The blogging engine that was initialized
18 | :type engine: object
19 | """)
20 |
21 | post_processed = signals.signal("post_processed", doc="""\
22 | Signal sent when a post is processed (i.e., the markdown is converted
23 | to html text). The arguments passed along with this signal are:
24 |
25 | :param app: The Flask app which is the sender
26 | :type app: object
27 | :param engine: The blogging engine that was initialized
28 | :type engine: object
29 | :param post: The post object which was processed
30 | :type post: dict
31 | :param render: Flag to denote if the post is to be rendered or not
32 | :type render: bool
33 | """)
34 |
35 | page_by_id_fetched = signals.signal("page_by_id_fetched", doc="""\
36 | Signal sent when a blog page specified by ``id`` is fetched,
37 | and prior to the post being processed.
38 |
39 | :param app: The Flask app which is the sender
40 | :type app: object
41 | :param engine: The blogging engine that was initialized
42 | :type engine: object
43 | :param post: The post object which was fetched
44 | :type post: dict
45 | :param meta: The metadata associated with that page
46 | :type meta: dict
47 | """)
48 | page_by_id_processed = signals.signal("page_by_id_generated", doc="""\
49 | Signal sent when a blog page specified by ``id`` is fetched,
50 | and prior to the post being processed.
51 |
52 | :param app: The Flask app which is the sender
53 | :type app: object
54 | :param engine: The blogging engine that was initialized
55 | :type engine: object
56 | :param post: The post object which was processed
57 | :type post: dict
58 | :param meta: The metadata associated with that page
59 | :type meta: dict
60 | """)
61 |
62 | posts_by_tag_fetched = signals.signal("posts_by_tag_fetched", doc="""\
63 | Signal sent when posts are fetched for a given tag but before processing
64 |
65 | :param app: The Flask app which is the sender
66 | :type app: object
67 | :param engine: The blogging engine that was initialized
68 | :type engine: object
69 | :param posts: Lists of post fetched with a given tag
70 | :type posts: list
71 | :param meta: The metadata associated with that page
72 | :type meta: dict
73 | """)
74 |
75 | posts_by_tag_processed = signals.signal("posts_by_tag_generated", doc="""\
76 | Signal sent after posts for a given tag were fetched and processed
77 |
78 | :param app: The Flask app which is the sender
79 | :type app: object
80 | :param engine: The blogging engine that was initialized
81 | :type engine: object
82 | :param posts: Lists of post fetched and processed with a given tag
83 | :type posts: list
84 | :param meta: The metadata associated with that page
85 | :type meta: dict
86 | """)
87 |
88 | posts_by_author_fetched = signals.signal("posts_by_author_fetched", doc="""\
89 | Signal sent after posts by an author were fetched but before processing
90 |
91 | :param app: The Flask app which is the sender
92 | :type app: object
93 | :param engine: The blogging engine that was initialized
94 | :type engine: object
95 | :param posts: Lists of post fetched with a given author
96 | :type posts: list
97 | :param meta: The metadata associated with that page
98 | :type meta: dict
99 | """)
100 | posts_by_author_processed = signals.signal("posts_by_author_generated",
101 | doc="""\
102 | Signal sent after posts by an author were fetched and processed
103 |
104 | :param app: The Flask app which is the sender
105 | :type app: object
106 | :param engine: The blogging engine that was initialized
107 | :type engine: object
108 | :param posts: Lists of post fetched and processed with a given author
109 | :type posts: list
110 | :param meta: The metadata associated with that page
111 | :type meta: dict
112 | """)
113 |
114 | index_posts_fetched = signals.signal("index_posts_fetched", doc="""\
115 | Signal sent after the posts for the index page are fetched
116 |
117 | :param app: The Flask app which is the sender
118 | :type app: object
119 | :param engine: The blogging engine that was initialized
120 | :type engine: object
121 | :param posts: Lists of post fetched for the index page
122 | :type posts: list
123 | :param meta: The metadata associated with that page
124 | :type meta: dict
125 | """)
126 |
127 | index_posts_processed = signals.signal("index_posts_processed", doc="""\
128 | Signal sent after the posts for the index page are fetched and processed
129 |
130 | :param app: The Flask app which is the sender
131 | :type app: object
132 | :param engine: The blogging engine that was initialized
133 | :type engine: object
134 | :param posts: Lists of post fetched and processed with a given author
135 | :type posts: list
136 | :param meta: The metadata associated with that page
137 | :type meta: dict
138 | """)
139 |
140 | feed_posts_fetched = signals.signal("feed_posts_fetched", doc="""\
141 | Signal send after feed posts are fetched
142 |
143 | :param app: The Flask app which is the sender
144 | :type app: object
145 | :param engine: The blogging engine that was initialized
146 | :type engine: object
147 | :param posts: Lists of post fetched and processed with a given author
148 | :type posts: list
149 | """)
150 | feed_posts_processed = signals.signal("feed_posts_processed", doc="""\
151 | Signal send after feed posts are processed
152 |
153 | :param app: The Flask app which is the sender
154 | :type app: object
155 | :param engine: The blogging engine that was initialized
156 | :type engine: object
157 | :param feed: Feed of post fetched and processed
158 | :type feed: list
159 | """)
160 |
161 | sitemap_posts_fetched = signals.signal("sitemap_posts_fetched", doc="""\
162 | Signal send after posts are fetched
163 |
164 | :param app: The Flask app which is the sender
165 | :type app: object
166 | :param engine: The blogging engine that was initialized
167 | :type engine: object
168 | :param posts: Lists of post fetched and processed with a given author
169 | :type posts: list
170 | """)
171 | sitemap_posts_processed = signals.signal("sitemap_posts_processed", doc="""\
172 | Signal send after posts are fetched and processed
173 |
174 | :param app: The Flask app which is the sender
175 | :type app: object
176 | :param engine: The blogging engine that was initialized
177 | :type engine: object
178 | :param posts: Lists of post fetched and processed with a given author
179 | :type posts: list
180 | """)
181 |
182 | editor_post_saved = signals.signal("editor_post_saved", doc="""\
183 | Signal sent after a post was saved during the POST request
184 |
185 | :param app: The Flask app which is the sender
186 | :type app: object
187 | :param engine: The blogging engine that was initialized
188 | :type engine: object
189 | :param post_id: The id of the post that was deleted
190 | :type post_id: int
191 | :param user: The user object
192 | :type user: object
193 | :param post: The post that was deleted
194 | :type post: object
195 |
196 | """)
197 | editor_get_fetched = signals.signal("editor_get_fetched", doc="""\
198 | Signal sent after fetching the post during the GET request
199 |
200 | :param app: The Flask app which is the sender
201 | :type app: object
202 | :param engine: The blogging engine that was initialized
203 | :type engine: object
204 | :param post_id: The id of the post that was deleted
205 | :type post_id: int
206 | :param form: The form prepared for the editor display
207 | :type form: object
208 | """)
209 |
210 | post_deleted = signals.signal("post_deleted", doc="""\
211 | The signal sent after the post is deleted.
212 |
213 | :param app: The Flask app which is the sender
214 | :type app: object
215 | :param engine: The blogging engine that was initialized
216 | :type engine: object
217 | :param post_id: The id of the post that was deleted
218 | :type post_id: int
219 | :param post: The post that was deleted
220 | :type post: object
221 | """)
222 |
223 | blueprint_created = signals.signal("blueprint_created", doc="""\
224 | The signal sent after the blueprint is created. A good time to
225 | add other views to the blueprint.
226 |
227 | :param app: The Flask app which is the sender
228 | :type app: object
229 | :param engine: The blogging engine that was initialized
230 | :type engine: object
231 | :param blueprint: The blog app blueprint
232 | :type blueprint: object
233 |
234 | """)
235 |
236 | sqla_initialized = signals.signal("sqla_initialized", doc="""\
237 | Signal sent after the SQLAStorage object is initialized
238 |
239 | :param sqlastorage: The SQLAStorage object
240 | :type sqlastorage: object
241 | :param engine: The blogging engine that was initialized
242 | :type engine: object
243 | :param table_prefix: The prefix to use for tables
244 | :type table_prefix: str
245 | :param meta: The metadata for the database
246 | :type meta: object
247 | :param bind: The bind value in the multiple db scenario.
248 | :type bind: object
249 | """)
250 |
--------------------------------------------------------------------------------
Comments
56 | 57 |