├── migrations ├── README ├── script.py.mako ├── versions │ ├── a908fb8b1d23_.py │ ├── 5346190a2f75_.py │ ├── 587d8fd64a42_.py │ ├── 981d0f4fdccf_.py │ ├── 14de905dee13_.py │ ├── a31adf717e90_.py │ ├── 6ee795a8d431_.py │ ├── d3d693b8dd81_.py │ ├── aa5eed55ef06_.py │ ├── d2028f25feb9_.py │ ├── 7f70ea52634f_.py │ ├── 33cbe65e05db_.py │ ├── fd06815c1b86_.py │ ├── a9d7af936cb5_.py │ └── be7c190835cc_.py ├── alembic.ini └── env.py ├── boot.sh ├── app ├── api │ ├── __init__.py │ └── routes.py ├── auth │ ├── __init__.py │ ├── forms.py │ └── routes.py ├── main │ ├── __init__.py │ └── routes.py ├── blogging │ ├── __init__.py │ └── routes.py ├── templates │ ├── email │ │ ├── email_post.txt │ │ ├── reset_password.txt │ │ ├── email_post.html │ │ ├── reminder.txt │ │ ├── cc_declined.txt │ │ └── reminder_cc.txt │ ├── auth │ │ ├── register.html │ │ ├── reset_password.html │ │ ├── adminsetup.html │ │ ├── login.html │ │ └── account.html │ ├── blogging │ │ ├── sitemap.xml │ │ ├── analytics.html │ │ ├── social_links.html │ │ ├── metatags.html │ │ ├── index.html │ │ ├── page.html │ │ ├── editor.html │ │ └── base.html │ ├── admin │ │ ├── email.html │ │ ├── theme.html │ │ ├── ga.html │ │ ├── index.html │ │ ├── btcpay.html │ │ ├── isso.html │ │ ├── square.html │ │ └── custom_list.html │ └── main │ │ ├── support.html │ │ ├── homepage.html │ │ └── creditcard.html ├── admin_utils │ ├── __init__.py │ ├── utils.py │ └── routes.py ├── static │ ├── custom.css │ ├── sqpaymentform-basic.css │ ├── custom-old.css │ └── sqpaymentform-basic.js ├── subscriptions.py ├── utils.py ├── admin_views │ └── forms.py ├── tasks.py ├── models.py ├── __init__.py └── email.py ├── tests ├── functional │ ├── test_users.py │ └── test_scheduler.py ├── unit │ └── test_models.py ├── tconfig.py └── conftest.py ├── TEST-CC-CHARGE.md ├── alternate_install ├── librepatron.env ├── isso.env └── alternate-install-docker.md ├── flask_blogging_patron ├── templates │ ├── blogging │ │ ├── sitemap.xml │ │ ├── analytics.html │ │ ├── disqus.html │ │ ├── social_links.html │ │ ├── metatags.html │ │ ├── index.html │ │ ├── page.html │ │ ├── editor.html │ │ └── base.html │ └── fileupload │ │ └── base.html ├── __init__.py ├── utils.py ├── forms.py ├── processor.py ├── storage.py ├── engine.py └── signals.py ├── Dockerfile ├── .github └── ISSUE_TEMPLATE │ └── bug_report.md ├── CHANGELOG.md ├── docker_boot.py ├── patron.py ├── requirements.txt ├── LICENSE ├── deprecated ├── opt-librepatron.template.yml └── luna-installer.sh ├── DEVELOPMENT.md ├── SSH.md ├── docker-compose.yml ├── upgrades.md ├── .gitignore ├── .circleci └── config.yml ├── config.py └── README.md /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /boot.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | flask db upgrade 3 | wait 4 | python3 docker_boot.py & 5 | exec gunicorn patron:app 6 | -------------------------------------------------------------------------------- /app/api/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('api', __name__) 4 | 5 | from app.api import routes 6 | -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('auth', __name__) 4 | 5 | from app.auth import routes 6 | -------------------------------------------------------------------------------- /app/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('main', __name__) 4 | 5 | from app.main import routes 6 | -------------------------------------------------------------------------------- /app/blogging/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | This overrides the blueprint auto-generated by Flask-Blogging. 3 | ''' 4 | from app import temp_bp 5 | 6 | bp = temp_bp 7 | 8 | from app.blogging import routes 9 | -------------------------------------------------------------------------------- /app/templates/email/email_post.txt: -------------------------------------------------------------------------------- 1 | {{ post.title }} 2 | 3 | {{ post.text }} 4 | 5 | If you want to unsubscribe from these updates, log into your account: {{ url_for('auth.account', _external=True) }} 6 | -------------------------------------------------------------------------------- /app/admin_utils/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | # setup admin routes that are outside of Flask-Admin 4 | bp = Blueprint('admin_utils', __name__) 5 | 6 | from app.admin_utils import routes # noqa: F401 7 | -------------------------------------------------------------------------------- /app/templates/email/reset_password.txt: -------------------------------------------------------------------------------- 1 | Dear {{ user.username }}, 2 | 3 | To reset your password click on the following link: 4 | 5 | {{ url_for('auth.reset_password', token=token, _external=True) }} 6 | 7 | If you have not requested a password reset simply ignore this message. 8 | -------------------------------------------------------------------------------- /app/templates/email/email_post.html: -------------------------------------------------------------------------------- 1 |

{{ post.title }}

2 |
3 | {{ post.rendered_text | safe }} 4 |
5 |
6 |
7 |

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 |

Sign Up to be a Patron

6 |
7 |
8 | {{ wtf.quick_form(form, button_map=config.BUTTON_MAP) }} 9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/templates/auth/reset_password.html: -------------------------------------------------------------------------------- 1 | {% extends "blogging/base.html" %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block main %} 5 |

Reset Your Password

6 |
7 |
8 | {{ wtf.quick_form(form, button_map=config.BUTTON_MAP) }} 9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /app/templates/auth/adminsetup.html: -------------------------------------------------------------------------------- 1 | {% extends "blogging/base.html" %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block main %} 5 |

Register as Administrator

6 |
7 |
8 | {{ wtf.quick_form(form, button_map=config.BUTTON_MAP) }} 9 |
10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /TEST-CC-CHARGE.md: -------------------------------------------------------------------------------- 1 | Running a credit card on Square with the following info should always be approved. 2 | 3 | Do not run a test charge while logged in as admin. Log out and log in as a regular user. 4 | 5 | 6 | Card Number: 4532759734545858 7 | CVV: any three non-consecutive numbers 8 | Expiration Date: any month and year in the future 9 | Postal Code: 94103 10 | -------------------------------------------------------------------------------- /app/static/custom.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 115px; 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 | } -------------------------------------------------------------------------------- /alternate_install/librepatron.env: -------------------------------------------------------------------------------- 1 | # REQUIRED: enter your site URL for all three of these 2 | # IMPORTANT: include 'https' on SITEURL, but not the other two 3 | SITEURL=https://example.com 4 | VIRTUAL_HOST=example.com 5 | LETSENCRYPT_HOST=example.com 6 | 7 | # REQUIRED: enter the email for Let's Encrypt notifications 8 | # can be the same as above 9 | LETS_ENCRYPT_EMAIL=email@email.com 10 | -------------------------------------------------------------------------------- /app/templates/blogging/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% for post in posts %} 5 | 6 | {{ config.BLOGGING_SITEURL }}{{ post.url }} 7 | {{ post.priority }} 8 | {{post.last_modified_date.isoformat()}} 9 | 10 | {% endfor %} 11 | 12 | -------------------------------------------------------------------------------- /flask_blogging_patron/templates/blogging/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% for post in posts %} 5 | 6 | {{ config.BLOGGING_SITEURL }}{{ post.url }} 7 | {{ post.priority }} 8 | {{post.last_modified_date.isoformat()}} 9 | 10 | {% endfor %} 11 | 12 | -------------------------------------------------------------------------------- /alternate_install/isso.env: -------------------------------------------------------------------------------- 1 | # REQUIRED: this must be the comments subdomain of your main domain 2 | # example: if your LibrePatron is on 'example.com', then this should be 'comments.example.com' 3 | # entry should be the same for VIRTUAL_HOST and LETSENCRYPT_HOST 4 | VIRTUAL_HOST=comments.example.com 5 | LETSENCRYPT_HOST=comments.example.com 6 | 7 | # enter the email for Let's Encrypt notifications 8 | LETS_ENCRYPT_EMAIL=example@example.com 9 | -------------------------------------------------------------------------------- /tests/functional/test_scheduler.py: -------------------------------------------------------------------------------- 1 | from flask_ezmail.connection import email_dispatched 2 | from time import sleep 3 | 4 | 5 | def test_scheduler(test_client, new_user, init_database): 6 | messages = [] 7 | 8 | @email_dispatched.connect 9 | def suppressed_mail(message): 10 | messages.append(message) 11 | 12 | test_client.get('/') 13 | sleep(65) 14 | message = messages[0] 15 | assert message.recipients == [new_user.email] 16 | -------------------------------------------------------------------------------- /app/templates/blogging/analytics.html: -------------------------------------------------------------------------------- 1 | {% if config.BLOGGING_GOOGLE_ANALYTICS %} 2 | 3 | 4 | 11 | {% endif %} -------------------------------------------------------------------------------- /flask_blogging_patron/__init__.py: -------------------------------------------------------------------------------- 1 | from .engine import BloggingEngine 2 | from .processor import PostProcessor 3 | from .sqlastorage import SQLAStorage 4 | from .storage import Storage 5 | 6 | 7 | """ 8 | Flask-Blogging is a Flask extension to add blog support to your 9 | web application. This extension uses Markdown to store and then 10 | render the webpage. 11 | 12 | Author: Gouthaman Balaraman 13 | 14 | Date: June 1, 2015 15 | """ 16 | 17 | __author__ = 'Gouthaman Balaraman' 18 | __version__ = '1.1.0' 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7.2-alpine3.8 2 | 3 | WORKDIR /patron 4 | 5 | COPY . /patron 6 | 7 | RUN apk add --no-cache gcc musl-dev libffi libffi-dev python3-dev openssl-dev tzdata linux-headers 8 | RUN ln -sf /usr/share/zoneinfo/Universal /etc/localtime 9 | RUN pip install -r requirements.txt 10 | RUN chmod +x boot.sh 11 | 12 | ENV FLASK_APP=patron.py 13 | ENV TZ=Universal 14 | ENV GUNICORN_CMD_ARGS="--bind=0.0.0.0:8006 --workers=3 --graceful-timeout 15 --access-logfile=- --error-logfile=-" 15 | 16 | EXPOSE 8006 17 | 18 | ENTRYPOINT ["./boot.sh"] 19 | -------------------------------------------------------------------------------- /tests/unit/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | def test_new_user(new_user): 5 | ''' 6 | GIVEN a User model 7 | WHEN new User is created 8 | THEN check the email, hashed pass, authentication 9 | expiration, password reset 10 | ''' 11 | assert new_user.username == 'test' 12 | assert new_user.email == 'test@test.com' 13 | assert datetime.today() > new_user.expiration 14 | assert new_user.check_password('test') 15 | assert new_user.role == 'Patron' 16 | assert not new_user.mail_opt_out 17 | -------------------------------------------------------------------------------- /flask_blogging_patron/utils.py: -------------------------------------------------------------------------------- 1 | def ensureUtf(s, encoding='utf8'): 2 | """Converts input to unicode if necessary. 3 | If `s` is bytes, it will be decoded using the `encoding` parameters. 4 | This function is used for preprocessing /source/ and /filename/ arguments 5 | to the builtin function `compile`. 6 | """ 7 | # In Python2, str == bytes. 8 | # In Python3, bytes remains unchanged, but str means unicode 9 | # while unicode is not defined anymore 10 | if type(s) == bytes: 11 | return s.decode(encoding, 'ignore') 12 | else: 13 | return s 14 | -------------------------------------------------------------------------------- /app/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'blogging/base.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block main %} 5 |

Sign In

6 |
7 |
8 | {{ wtf.quick_form(form, button_map=config.BUTTON_MAP) }} 9 |
10 |
11 |
12 |

New User? Click to Register!

13 |

14 | Forgot Your Password? 15 | Click to Reset It 16 |

17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /app/templates/admin/email.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block body %} 5 |

Email Setup

6 | {% if email %} 7 |

Email is currently paired.

8 |

Send Test Email

9 |
10 | {% endif %} 11 |

Your server must support TLS.

12 |
13 |
14 |
15 | {{ wtf.quick_form(form) }} 16 |
17 |
18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /app/templates/admin/theme.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block body %} 5 |

Choose Your Theme

6 |
7 |

Current theme: {{ current_theme.capitalize() }} 8 |

9 |
10 | {{ wtf.quick_form(form) }} 11 |
12 |
13 |
14 |

Upon hitting "submit", expect a delay of several seconds while your site configuration reloads.

15 |

You can preview themes here. 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /app/templates/admin/ga.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block body %} 5 |

Enter your Google Analytics Info

6 |
7 | {% if ga %} 8 |

Google Analytics is currently paired. Re-pair below.

9 |
10 | {% endif %} 11 |
12 |
13 | {{ wtf.quick_form(form) }} 14 |
15 |
16 |
17 |

To deactivate Google Analytics, click below

18 |

Deactivate Google Analytics

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 |

LibrePatron Version: {{ version }}

7 |

Click Here to Check for Upgrades

8 |
9 |

Choose from the menu options above, or click below to return.

10 |
11 |

Return to Main Site

12 | {% else %} 13 |

You do not have permission to view this page.

14 | {% endif %} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /migrations/versions/a908fb8b1d23_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: a908fb8b1d23 4 | Revises: fd06815c1b86 5 | Create Date: 2019-01-17 20:24:04.513429 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a908fb8b1d23' 14 | down_revision = 'fd06815c1b86' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | pass 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | pass 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 |

v0.7.39

2 | 3 | * update werkzeug version 4 | 5 |

v0.7.37

6 | 7 | * ensure dummy isso config always is created if not present 8 | 9 |

v0.7.34

10 | 11 | * update urllib dependency 12 | 13 |

v0.7.34

14 | 15 | * update jinja2 and SQLAlchemy dependencies 16 | 17 |

v0.7.31

18 | 19 | * bug fix: comments not loading after reactivate/deactivate isso 20 | 21 | * remove author metadata 22 | 23 |

v0.7.27

24 | 25 | * user customizable themes 26 | 27 | * locally host SimpleMDE javascript files 28 | 29 |

v0.6.78

30 | 31 | * persist APScheduler to SQLite 32 | 33 | * fix bad indent on IPN handling 34 | 35 | * fix lack of space in IPN logging 36 | -------------------------------------------------------------------------------- /app/subscriptions.py: -------------------------------------------------------------------------------- 1 | from app import blog_engine 2 | from app.email import email_post 3 | from flask_blogging_patron.signals import editor_post_saved 4 | 5 | ''' 6 | Subscribes to editor_post_saved signal from Flask-Blogging. 7 | Upon receiving the signal, it emails the post to all paid 8 | subscribers unless the post is marked 'noemail.' 9 | ''' 10 | 11 | 12 | @editor_post_saved.connect 13 | def email_trigger(sender, engine, post_id, user, post): 14 | send_post = blog_engine.storage.get_post_by_id(post_id) 15 | for tag in send_post['tags']: 16 | email = True 17 | if tag.lower() == 'public' or tag.lower() == 'noemail': 18 | email = False 19 | break 20 | if email: 21 | email_post(send_post) 22 | -------------------------------------------------------------------------------- /migrations/versions/5346190a2f75_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 5346190a2f75 4 | Revises: a908fb8b1d23 5 | Create Date: 2019-01-28 03:05:26.706536 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '5346190a2f75' 14 | down_revision = 'a908fb8b1d23' 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('user', sa.Column('last_payment', sa.String(length=128), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('user', 'last_payment') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/587d8fd64a42_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 587d8fd64a42 4 | Revises: a9d7af936cb5 5 | Create Date: 2019-01-08 15:45:58.171518 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '587d8fd64a42' 14 | down_revision = 'a9d7af936cb5' 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('square', sa.Column('access_token', sa.String(length=200), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('square', 'access_token') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /app/blogging/routes.py: -------------------------------------------------------------------------------- 1 | from app.blogging import bp 2 | from datetime import datetime 3 | from flask import flash, redirect, url_for 4 | from flask_login import current_user 5 | 6 | 7 | @bp.before_request 8 | def protect(): 9 | ''' 10 | Registers new function to Flask-Blogging Blueprint that protects 11 | updates to make them only viewable by paid subscribers. 12 | ''' 13 | if current_user.is_authenticated: 14 | if datetime.today() <= current_user.expiration: 15 | return None 16 | else: 17 | flash('You must have a paid-up subscription \ 18 | to view updates.', 'warning') 19 | return redirect(url_for('main.support')) 20 | else: 21 | flash('Please login to view updates.', 'warning') 22 | return redirect(url_for('auth.login')) 23 | -------------------------------------------------------------------------------- /flask_blogging_patron/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, TextAreaField, SubmitField, BooleanField,\ 3 | RadioField, HiddenField 4 | from wtforms.validators import DataRequired 5 | 6 | 7 | class BlogEditor(FlaskForm): 8 | title = StringField("title", validators=[DataRequired()]) 9 | text = TextAreaField("text") 10 | tags = RadioField( 11 | 'Optional Parameters', 12 | choices=[ 13 | ('NORMAL', 'Post this as a subscriber update and email to all subscribers.'), 14 | ('NOEMAIL', 'Post this as a subscriber update, but do not email it.'), 15 | ] 16 | ) 17 | draft = BooleanField("draft", default=False) 18 | submit = SubmitField("submit") 19 | 20 | 21 | class HomePageEditor(BlogEditor): 22 | tags = HiddenField(default='PUBLIC') 23 | -------------------------------------------------------------------------------- /flask_blogging_patron/templates/blogging/analytics.html: -------------------------------------------------------------------------------- 1 | {% if config.BLOGGING_GOOGLE_ANALYTICS %} 2 | 15 | {% endif %} -------------------------------------------------------------------------------- /migrations/versions/981d0f4fdccf_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 981d0f4fdccf 4 | Revises: 6ee795a8d431 5 | Create Date: 2018-12-28 13:33:15.337819 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '981d0f4fdccf' 14 | down_revision = '6ee795a8d431' 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('secret_key', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('key', sa.String(length=64), nullable=True), 24 | sa.PrimaryKeyConstraint('id') 25 | ) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_table('secret_key') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/14de905dee13_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 14de905dee13 4 | Revises: be7c190835cc 5 | Create Date: 2018-12-26 14:37:52.538066 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '14de905dee13' 14 | down_revision = 'be7c190835cc' 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('btc_pay_client_store', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('client', sa.PickleType(), nullable=True), 24 | sa.PrimaryKeyConstraint('id') 25 | ) 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade(): 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | op.drop_table('btc_pay_client_store') 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/a31adf717e90_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: a31adf717e90 4 | Revises: 7f70ea52634f 5 | Create Date: 2019-01-15 20:30:47.710860 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a31adf717e90' 14 | down_revision = '7f70ea52634f' 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('user', sa.Column('renew', sa.Boolean(), nullable=True)) 22 | op.create_index(op.f('ix_user_renew'), 'user', ['renew'], unique=False) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_index(op.f('ix_user_renew'), table_name='user') 29 | op.drop_column('user', 'renew') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /docker_boot.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import time 4 | 5 | ''' 6 | Some configuration options are loaded from the database, 7 | however the database cannot be loaded until after Flask app creation. 8 | Therefore, some config has to be done by registering the config 9 | functions with @app.before_first_request decorator in the app 10 | factory. The script in this file will continually make GET requests 11 | to LibrePatron until it gets a valid 200 response when a Docker 12 | container boots. This will force those config functions to load upon boot. 13 | ''' 14 | 15 | 16 | def load_config(): 17 | while True: 18 | try: 19 | r = requests.get( 20 | 'https://' + os.environ.get('VIRTUAL_HOST') 21 | ) 22 | if r.status_code == 200: 23 | break 24 | except Exception: 25 | pass 26 | time.sleep(2) 27 | 28 | 29 | if __name__ == '__main__': 30 | load_config() 31 | -------------------------------------------------------------------------------- /migrations/versions/6ee795a8d431_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 6ee795a8d431 4 | Revises: 14de905dee13 5 | Create Date: 2018-12-26 20:21:49.914278 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '6ee795a8d431' 14 | down_revision = '14de905dee13' 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('user', sa.Column('mail_opt_out', sa.Boolean(), nullable=True)) 22 | op.create_index(op.f('ix_user_mail_opt_out'), 'user', ['mail_opt_out'], unique=False) 23 | # ### end Alembic commands ### 24 | 25 | 26 | def downgrade(): 27 | # ### commands auto generated by Alembic - please adjust! ### 28 | op.drop_index(op.f('ix_user_mail_opt_out'), table_name='user') 29 | op.drop_column('user', 'mail_opt_out') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/versions/d3d693b8dd81_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: d3d693b8dd81 4 | Revises: d2028f25feb9 5 | Create Date: 2019-01-07 19:45:49.636979 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'd3d693b8dd81' 14 | down_revision = 'd2028f25feb9' 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('square', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('application_id', sa.String(length=128), nullable=True), 24 | sa.Column('location_id', sa.String(length=128), nullable=True), 25 | sa.PrimaryKeyConstraint('id') 26 | ) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_table('square') 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /app/templates/admin/btcpay.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block body %} 5 |

Enter your BTCPay Pairing Info

6 |
7 | {% if btcpay %} 8 |

BTCPay is currently paired. Re-pair below.

9 |
10 |
Format for BTCPay URL: 11 |
    12 |
  1. Include 'https://'.
  2. 13 |
  3. Exclude trailing '/'
  4. 14 |
  5. Example: https://btcpay.example.com
  6. 15 |
16 | {% endif %} 17 |
18 |
19 | {{ wtf.quick_form(form) }} 20 |
21 |
22 |
23 | {% if btcpay_host %} 24 |

You can obtain your code by heading here.

25 | {% else %} 26 |

You can obtain your code by heading to "[your BTCPay URL]/api-tokens".

27 | {% endif %} 28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /migrations/versions/aa5eed55ef06_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: aa5eed55ef06 4 | Revises: 587d8fd64a42 5 | Create Date: 2019-01-10 13:40:47.791066 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'aa5eed55ef06' 14 | down_revision = '587d8fd64a42' 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('third_party_services', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(length=64), nullable=True), 24 | sa.Column('code', sa.String(length=128), nullable=True), 25 | sa.PrimaryKeyConstraint('id') 26 | ) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_table('third_party_services') 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /migrations/versions/d2028f25feb9_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: d2028f25feb9 4 | Revises: 981d0f4fdccf 5 | Create Date: 2019-01-07 13:12:53.808847 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'd2028f25feb9' 14 | down_revision = '981d0f4fdccf' 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('square_client', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('application_id', sa.String(length=128), nullable=True), 24 | sa.Column('location_id', sa.String(length=128), nullable=True), 25 | sa.PrimaryKeyConstraint('id') 26 | ) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_table('square_client') 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /flask_blogging_patron/templates/blogging/disqus.html: -------------------------------------------------------------------------------- 1 | {% if config.BLOGGING_DISQUS_SITENAME %} 2 |
3 |
4 |
5 | 6 | 15 | 16 | 17 | {% endif %} 18 | -------------------------------------------------------------------------------- /app/templates/auth/account.html: -------------------------------------------------------------------------------- 1 | {% extends "blogging/base.html" %} 2 | 3 | {% block main %} 4 |

Account Information

5 |

Plan Name: {{ current_user.role }}

6 |

Expiration (UTC): {{ expires }}

7 | {% if not square %} 8 |

Note that if you made a payment today, your full 30 day extension will not be reflected until the blockchain confirms payment.

9 | {% else %} 10 |

Your card on file will be automatically charged on your expiration date.

11 | {% endif %} 12 | Make a Payment or Change Plans 13 | {% if square %} 14 | Cancel Credit Card Billing 15 | {% endif %} 16 | {% if opt_out %} 17 | Opt In to Emails 18 | {% else %} 19 | Opt Out of Emails 20 | {% endif %} 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /patron.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018 Jeff Vandrew Jr 2 | 3 | from app import create_app, db, blog_engine 4 | from app.email import send_reminder_emails 5 | from app.models import User, BTCPayClientStore, PriceLevel, \ 6 | ThirdPartyServices, Email 7 | from datetime import datetime, timedelta 8 | from flask_blogging_patron.signals import editor_post_saved 9 | 10 | app = create_app() 11 | 12 | 13 | if __name__ == '__main__': 14 | app.run(load_dotenv=True, ssl_context='adhoc') 15 | 16 | 17 | @app.shell_context_processor 18 | def make_shell_context(): 19 | return { 20 | 'db': db, 'User': User, 21 | 'editor_post_saved': editor_post_saved, 22 | 'blog_engine': blog_engine, 23 | 'Email': Email, 24 | 'ThirdPartyServices': ThirdPartyServices, 25 | 'BTCPayClientStore': BTCPayClientStore, 26 | 'PriceLevel': PriceLevel, 27 | 'send_reminder_emails': send_reminder_emails, 28 | 'tomorrow': datetime.today() + timedelta(hours=24), 29 | 'yesterday': datetime.today() - timedelta(hours=24), 30 | } 31 | -------------------------------------------------------------------------------- /app/admin_utils/utils.py: -------------------------------------------------------------------------------- 1 | from app.models import ThirdPartyServices 2 | from configparser import ConfigParser 3 | from flask import current_app 4 | 5 | 6 | def isso_config(): 7 | # isso requires a config file 8 | # this function writes a config file in isso format 9 | # file is saved in a Docker volume shared between lp and isso 10 | file = current_app.config['ISSO_CONFIG_PATH'] 11 | isso_pass = ThirdPartyServices.query.filter_by( 12 | name='isso').first().code 13 | isso_config = ConfigParser() 14 | isso_config['general'] = {} 15 | isso_config['general']['dbpath'] = current_app.config['COMMENTS_DB_PATH'] 16 | isso_config['general']['host'] = current_app.config['BLOGGING_SITEURL'] 17 | isso_config['admin'] = {} 18 | isso_config['admin']['enabled'] = 'true' 19 | isso_config['admin']['password'] = isso_pass 20 | isso_config['guard'] = {} 21 | isso_config['guard']['ratelimit'] = '50' 22 | isso_config['guard']['direct-reply'] = '100' 23 | with open(file, 'w') as configfile: 24 | isso_config.write(configfile) 25 | -------------------------------------------------------------------------------- /app/templates/admin/isso.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block body %} 5 |

Comment Setup

6 |
7 | {% if isso %} 8 |

To moderate comments, click below

9 |

Moderate Comments

10 |
11 |

To deactivate comments, click below

12 |

This will not delete your comment database; you can reactivate later.

13 |

Note that when deactivating and reactivating, there is a delay due to browser cache and comments server reset.

14 |

Deactivate Comments

15 | {% else %} 16 |

Note that when deactivating and reactivating, there is a delay due to browser cache and comments server reset.

17 |
18 |
19 | {{ wtf.quick_form(form) }} 20 |
21 |
22 |
23 | {% endif %} 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /app/templates/admin/square.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/master.html' %} 2 | {% import 'bootstrap/wtf.html' as wtf %} 3 | 4 | {% block body %} 5 |

Enter your Square Account Info

6 | {% if square %} 7 |

Square is currently paired. Re-pair below.

8 |
9 | {% endif %} 10 |
11 |
12 |
13 | {{ wtf.quick_form(form) }} 14 |
15 |
16 |
17 |

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.

21 |

Deactivate Square

22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /app/templates/blogging/social_links.html: -------------------------------------------------------------------------------- 1 |

2 |  Share on: 3 | Twitter 7 | / 8 | Facebook 10 | / 11 | Google+ 13 | / 14 | Email 17 |

-------------------------------------------------------------------------------- /flask_blogging_patron/templates/blogging/social_links.html: -------------------------------------------------------------------------------- 1 |

2 |  Share on: 3 | Twitter 7 | / 8 | Facebook 10 | / 11 | Google+ 13 | / 14 | Email 17 |

18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.0.5 2 | APScheduler==3.5.3 3 | asn1crypto==0.24.0 4 | blinker==1.4 5 | btcpay-python==1.1.0 6 | certifi==2018.11.29 7 | cffi==1.11.5 8 | chardet==3.0.4 9 | Click==7.0 10 | cryptography==2.4.2 11 | dominate==2.3.5 12 | ecdsa==0.13 13 | Flask==1.0.2 14 | Flask-APScheduler==1.11.0 15 | Flask-Admin==1.5.3 16 | Flask-Bootstrap==3.3.7.1 17 | Flask-Caching==1.4.0 18 | flask-ezmail==0.6.3 19 | Flask-FileUpload==0.5.0 20 | Flask-Login==0.4.1 21 | Flask-Migrate==2.3.1 22 | Flask-Principal==0.4.0 23 | Flask-SQLAlchemy==2.3.2 24 | Flask-WTF==0.14.2 25 | gunicorn==19.9.0 26 | idna==2.8 27 | itsdangerous==1.1.0 28 | Jinja2==2.10.1 29 | Mako==1.0.7 30 | Markdown==3.0.1 31 | MarkupSafe==1.1.0 32 | psutil>=5.4.0 33 | pycparser==2.19 34 | PyJWT==1.7.1 35 | pytest==4.0.2 36 | python-dateutil==2.7.5 37 | python-dotenv==0.10.1 38 | python-editor==1.0.3 39 | python-slugify==2.0.0 40 | pytz==2018.7 41 | requests==2.21.0 42 | shortuuid==0.5.0 43 | six==1.12.0 44 | SQLAlchemy==1.3.0 45 | squareconnect==2.20181212.0 46 | tzlocal==1.5.1 47 | Unidecode==1.0.23 48 | urllib3==1.24.2 49 | visitor==0.1.3 50 | Werkzeug==0.15.3 51 | WTForms==2.2.1 52 | -------------------------------------------------------------------------------- /app/templates/main/support.html: -------------------------------------------------------------------------------- 1 | {% extends "blogging/base.html" %} 2 | 3 | {% block main %} 4 |
5 |

Support Levels

6 |
7 |
8 |
9 | {% for level in levels %} 10 |
11 |
12 |

{{ level.name }}

13 |
14 |
15 |

${{ level.price }} / mo

16 |

{{ level.description }}

17 |

Subscribe (Bitcoin)

18 | {%if square %} 19 |

Subscribe (Fiat)

20 | {% endif %} 21 |
22 |
23 | {% endfor %} 24 |
25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /migrations/versions/7f70ea52634f_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 7f70ea52634f 4 | Revises: aa5eed55ef06 5 | Create Date: 2019-01-10 14:43:36.805369 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '7f70ea52634f' 14 | down_revision = 'aa5eed55ef06' 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('email', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('server', sa.String(length=128), nullable=True), 24 | sa.Column('port', sa.Integer(), nullable=True), 25 | sa.Column('username', sa.String(length=128), nullable=True), 26 | sa.Column('password', sa.String(length=128), nullable=True), 27 | sa.Column('outgoing_email', sa.String(length=128), nullable=True), 28 | sa.PrimaryKeyConstraint('id') 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_table('email') 36 | # ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /app/templates/admin/custom_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/model/list.html' %} 2 | 3 | {% block body %} 4 |

Pricing Setup

5 |

Use the table below to set up your subscription levels.

6 |
7 |

Notes if Square Recurring Billing is Active:

8 |
    9 |
  1. 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.
  2. 10 |
  3. 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.
  4. 11 |
  5. 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.
  6. 12 |
  7. Changing the description of a price level has no effects on recurring billing.
  8. 13 |
  9. None of these notes affect users who pay monthly by BTCPay reminder emails.
  10. 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 |
9 | {{ alert.type }} {{ alert.message }} 10 |
11 | {% endif %} 12 | 13 | {% if meta.is_user_blogger %} 14 |
15 |
16 | New Post 17 |
18 |
19 | {% endif %} 20 | 21 | {% for post in posts %} 22 | {% for tag in post.tags %} 23 | {% if 'public' not in tag.lower() %} 24 | 25 |

{{ post.title }}

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 | ![LunaNode](https://cdn-images-1.medium.com/max/800/1*YLwkQ_aoZuVme5EAIynlnA.png) 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 | ![Putty](https://cdn-images-1.medium.com/max/800/1*ldCagOzckSKupFlR9tUR8A.png) 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 | 29 |
30 |
31 | {% if post.editable %} 32 |
33 | Edit Homepage 34 |
35 | {% endif %} 36 |
37 | {% endif %} 38 | 39 | 40 |

{{ post.title }}

41 | {{post.rendered_text | safe}} 42 | 43 |
44 |
45 |
46 | {% endblock main %} 47 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | nginx-proxy: 4 | image: jwilder/nginx-proxy 5 | container_name: nginx-proxy 6 | ports: 7 | - '80:80' 8 | - '443:443' 9 | volumes: 10 | - '/etc/nginx/vhost.d' 11 | - '/usr/share/nginx/html' 12 | - '/etc/nginx/certs:/etc/nginx/certs:ro' 13 | - '/var/run/docker.sock:/tmp/docker.sock:ro' 14 | restart: on-failure 15 | 16 | letsencrypt-nginx-proxy: 17 | container_name: letsencrypt-nginx-proxy 18 | image: 'jrcs/letsencrypt-nginx-proxy-companion' 19 | volumes: 20 | - '/etc/nginx/certs:/etc/nginx/certs' 21 | - '/var/run/docker.sock:/var/run/docker.sock:ro' 22 | volumes_from: 23 | - nginx-proxy 24 | restart: on-failure 25 | 26 | librepatron: 27 | container_name: librepatron 28 | image: jvandrew/librepatron:0.7.39 29 | expose: 30 | - "8006" 31 | volumes: 32 | - data-volume:/var/lib/db 33 | - config-volume:/var/lib/config 34 | environment: 35 | - SECRET_KEY_LOCATION=/var/lib/db/key 36 | - DATABASE_URL=sqlite:////var/lib/db/app.db 37 | env_file: 38 | - librepatron.env 39 | restart: on-failure 40 | 41 | isso: 42 | container_name: isso 43 | image: jvandrew/isso:atron.22 44 | expose: 45 | - "8080" 46 | volumes: 47 | - data-volume:/var/lib/db 48 | - config-volume:/var/lib/config 49 | env_file: 50 | - isso.env 51 | restart: on-failure 52 | 53 | volumes: 54 | data-volume: 55 | driver: local 56 | config-volume: 57 | driver: local 58 | 59 | networks: 60 | default: 61 | external: 62 | name: nginx-net 63 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from app import create_app, db 2 | from app.models import User, PriceLevel, Email 3 | from tests.tconfig import Config 4 | from datetime import datetime, timedelta 5 | import pytest 6 | 7 | 8 | @pytest.fixture(scope='module') 9 | def new_user(): 10 | user = User( 11 | username='test', 12 | email='test@test.com', 13 | expiration=(datetime.today() - timedelta(hours=24)), 14 | renew=True, 15 | role='Patron', 16 | mail_opt_out=False 17 | ) 18 | user.set_password('test') 19 | return user 20 | 21 | 22 | @pytest.fixture(scope='session') 23 | def test_client(): 24 | app = create_app(config_class=Config) 25 | testing_client = app.test_client() 26 | ctx = app.app_context() 27 | ctx.push() 28 | yield testing_client 29 | ctx.pop() 30 | 31 | 32 | @pytest.fixture(scope='module') 33 | def test_mail(): 34 | mail = Email( 35 | server='junk', 36 | username='junk', 37 | password='junk', 38 | default_sender='junk@junk.com', 39 | use_tls=True, 40 | port=587, 41 | suppress=True 42 | ) 43 | return mail 44 | 45 | 46 | @pytest.fixture(scope='function') 47 | def init_database(new_user, test_mail): 48 | db.drop_all() 49 | db.create_all() 50 | db.session.add(new_user) 51 | db.session.add(test_mail) 52 | level_1 = PriceLevel( 53 | name='Patron', 54 | description="You're a patron!", 55 | price=10, 56 | ) 57 | level_2 = PriceLevel( 58 | name='Cooler Patron', 59 | description="You're a cooler patron!", 60 | price=20, 61 | ) 62 | level_3 = PriceLevel( 63 | name='Coolest Patron', 64 | description="You're the best!", 65 | price=60, 66 | ) 67 | db.session.add(level_1) 68 | db.session.add(level_2) 69 | db.session.add(level_3) 70 | db.session.commit() 71 | yield db 72 | db.drop_all() 73 | -------------------------------------------------------------------------------- /upgrades.md: -------------------------------------------------------------------------------- 1 |

Current Version of LibrePatron: 0.7.39

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 |
9 | {{ alert.type }} {{ alert.message }} 10 |
11 | {% endif %} 12 | 13 | {% if meta.is_user_blogger %} 14 | 19 | {% endif %} 20 | 21 | {% for post in posts %} 22 | 23 |

{{ post.title }}

24 |
25 |

Posted by {{post.user_name}} 26 | on {{post.post_date.strftime('%d %b, %Y')}}

27 | 28 | 29 | {% if post.tags %} 30 |    31 | {% for tag in post.tags %} 32 | 33 | 34 | {{ tag }} 35 | 36 |    37 | {% endfor %} 38 |
39 | {% endif %} 40 |
41 | {% endfor %} 42 | {% if ((meta) and (meta.max_pages>1)) %} 43 |
44 |
45 |
    46 | {% if meta.pagination.prev_page %} 47 |
  • « Prev
  • 48 | {% else %} 49 |
  • « Prev
  • 50 | {% endif %} 51 | {% if meta.pagination.next_page %} 52 |
  • Next »
  • 53 | {% else %} 54 |
  • Next »
  • 55 | {% endif %} 56 | 57 |
58 |
59 |
60 | {% endif %} 61 | {% endblock main %} 62 | -------------------------------------------------------------------------------- /app/auth/forms.py: -------------------------------------------------------------------------------- 1 | from app.models import User 2 | from flask_wtf import FlaskForm 3 | from wtforms import StringField, PasswordField, BooleanField, SubmitField 4 | from wtforms.validators import DataRequired, ValidationError, Email, EqualTo 5 | 6 | 7 | class LoginForm(FlaskForm): 8 | username = StringField('Username', validators=[DataRequired()]) 9 | password = PasswordField('Password', validators=[DataRequired()]) 10 | remember_me = BooleanField('Remember Me') 11 | submit = SubmitField('Sign In') 12 | 13 | 14 | class RegistrationForm(FlaskForm): 15 | username = StringField('Username', validators=[DataRequired()]) 16 | email = StringField('Email', validators=[DataRequired(), Email()]) 17 | password = PasswordField('Password', validators=[DataRequired()]) 18 | password2 = PasswordField('Repeat Password', validators=[ 19 | DataRequired(), 20 | EqualTo('password')] 21 | ) 22 | submit = SubmitField('Register') 23 | 24 | def validate_username(self, username): 25 | user = User.query.filter_by(username=username.data).first() 26 | if user is not None: 27 | raise ValidationError('Please choose a different username.') 28 | 29 | def validate_email(self, email): 30 | user = User.query.filter_by(email=email.data).first() 31 | if user is not None: 32 | raise ValidationError('There is already an account registered to\ 33 | this email address.') 34 | 35 | 36 | class AdminForm(FlaskForm): 37 | username = StringField('Username', validators=[DataRequired()]) 38 | email = StringField('Email', validators=[DataRequired(), Email()]) 39 | password = PasswordField('Password', validators=[DataRequired()]) 40 | password2 = PasswordField('Repeat Password', validators=[ 41 | DataRequired(), 42 | EqualTo('password')] 43 | ) 44 | submit = SubmitField('Register') 45 | 46 | 47 | class ResetPasswordRequestForm(FlaskForm): 48 | email = StringField('Email', validators=[DataRequired(), Email()]) 49 | submit = SubmitField('Request Password Reset') 50 | 51 | 52 | class ResetPasswordForm(FlaskForm): 53 | password = PasswordField('Password', validators=[DataRequired()]) 54 | password2 = PasswordField('Repeat Password', validators=[ 55 | DataRequired(), 56 | EqualTo('password')] 57 | ) 58 | submit = SubmitField('Reset Password') 59 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore 2 | import os 3 | from os.path import abspath, join 4 | import shelve 5 | 6 | basedir = abspath(os.path.dirname(__file__)) 7 | 8 | 9 | class Config(object): 10 | BLOGGING_SITENAME = os.environ.get('SITENAME') or 'LibrePatron' 11 | BLOGGING_SITEURL = os.environ.get('SITEURL') or 'https://example.com' 12 | BUTTON_MAP = {'submit': 'primary'} 13 | SERVER_NAME = os.environ.get('VIRTUAL_HOST') 14 | BLOGGING_URL_PREFIX = '/updates' 15 | BLOGGING_BRANDURL = os.environ.get('BRANDURL') 16 | BLOGGING_TWITTER_USERNAME = os.environ.get('TWITTER') 17 | BLOGGING_GOOGLE_ANALYTICS = None 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 = os.environ.get('ISSO_CONFIG_PATH') or \ 24 | '/var/lib/config/isso.cfg' 25 | COMMENTS_DB_PATH = os.environ.get('COMMENTS_DB_PATH') or \ 26 | '/var/lib/db/comments.db' 27 | COMMENTS = False 28 | COMMENTS_SUBURI = os.environ.get('COMMENTS_SUBURI') is not None 29 | if COMMENTS_SUBURI: 30 | COMMENTS_URL = BLOGGING_SITEURL + '/isso' 31 | elif SERVER_NAME is not None: 32 | COMMENTS_URL = 'https://comments.' + SERVER_NAME 33 | else: 34 | COMMENTS_URL = None 35 | PREFERRED_URL_SCHEME = 'https' 36 | if os.environ.get('SCHEDULER_HOUR') is not None: 37 | SCHEDULER_HOUR = int(os.environ.get('SCHEDULER_HOUR')) 38 | else: 39 | SCHEDULER_HOUR = 9 40 | if os.environ.get('SCHEDULER_MINUTE') is not None: 41 | SCHEDULER_MINUTE = int(os.environ.get('SCHEDULER_MINUTE')) 42 | else: 43 | SCHEDULER_MINUTE = None 44 | SECRET_KEY_LOCATION = os.environ.get('SECRET_KEY_LOCATION') or \ 45 | join(basedir, 'key') 46 | with shelve.open(SECRET_KEY_LOCATION) as key: 47 | if key.get('key') is None: 48 | SECRET_KEY = os.urandom(24).hex() 49 | key['key'] = SECRET_KEY 50 | else: 51 | SECRET_KEY = key['key'] 52 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 53 | 'sqlite:///' + join(basedir, 'app.db') 54 | SCHEDULER_JOBSTORES = { 55 | 'default': SQLAlchemyJobStore(url=SQLALCHEMY_DATABASE_URI) 56 | } 57 | SQLALCHEMY_TRACK_MODIFICATIONS = False 58 | THEME = 'spacelab' 59 | -------------------------------------------------------------------------------- /app/templates/main/creditcard.html: -------------------------------------------------------------------------------- 1 | {% extends 'blogging/base.html' %} 2 | {% block meta %} 3 | 7 | {% endblock meta %} 8 | {% block extrastyle %} 9 | 10 | 12 | 13 | 14 | 16 | 17 | 18 | {% endblock extrastyle %} 19 | {% block message %} 20 | {% endblock message %} 21 | {% block main %} 22 |
25 |
26 | 31 |
32 |
33 | Card Number 34 |
35 | 36 |
37 | Expiration 38 |
39 |
40 | 41 |
42 | CVV 43 |
44 |
45 | 46 |
47 | Postal 48 |
49 |
50 |
51 | 52 | 53 | 54 |
55 | 56 | 59 | 60 |
61 |
62 | 63 |
64 | {% endblock main %} 65 | {% block extrajs %} 66 | {% endblock extrajs %} 67 | 68 | -------------------------------------------------------------------------------- /app/admin_utils/routes.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from app.admin_utils import bp 3 | from app.email import send_email 4 | from app.models import Square, ThirdPartyServices, User, Email 5 | from app.utils import hup_gunicorn 6 | from flask import redirect, url_for, flash, current_app 7 | from flask_login import current_user 8 | from threading import Thread 9 | 10 | 11 | @bp.route('/deletesquare') 12 | def delete_square(): 13 | # deactivates square and converts all subs to Bitcoin billing 14 | square = Square.query.first() 15 | if square is not None: 16 | db.session.delete(square) 17 | cc_users = User.query.filter(User.square_id != None).all() 18 | for cc_user in cc_users: 19 | cc_user.square_id = None 20 | cc_user.square_card = None 21 | db.session.commit() 22 | flash('Square deactivated.') 23 | return redirect(url_for('admin.index')) 24 | 25 | 26 | @bp.route('/deletega') 27 | def delete_ga(): 28 | # deactivates Google Analytics 29 | ga = ThirdPartyServices.query.filter_by( 30 | name='ga').first() 31 | if ga is not None: 32 | db.session.delete(ga) 33 | db.session.commit() 34 | current_app.config['BLOGGING_GOOGLE_ANALYTICS'] = None 35 | flash('Google Analytics deactivated.') 36 | Thread(hup_gunicorn()).start() 37 | return redirect(url_for('admin.index')) 38 | 39 | 40 | @bp.route('/deactivateisso') 41 | def deactivate_isso(): 42 | # deactivates isso comments 43 | # does not delete the comments.db, so can be reactivated later 44 | # comment moderation password can be rest by deactivate/reactivate 45 | isso = ThirdPartyServices.query.filter_by( 46 | name='isso').first() 47 | if isso is not None: 48 | db.session.delete(isso) 49 | db.session.commit() 50 | current_app.config['COMMENTS'] = False 51 | flash(''' 52 | Comments deactivated. Due to browser caching, 53 | there can be a delay before comments disappear. 54 | ''') 55 | Thread(hup_gunicorn()).start() 56 | return redirect(url_for('admin.index')) 57 | 58 | 59 | @bp.route('/testemail') 60 | def test_email(): 61 | # sends a test email to ensure SMTP settings are correct 62 | email = Email.query.first() 63 | send_email( 64 | subject='Test Email', 65 | sender=email.default_sender, 66 | recipients=[current_user.email], 67 | text_body='Test Email.', 68 | html_body=None, 69 | ) 70 | flash('Test email sent to administrator.') 71 | return redirect(url_for('admin.index')) 72 | -------------------------------------------------------------------------------- /app/templates/blogging/page.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 | 29 |
30 | {% if post.editable %} 31 |
32 | Delete 33 |
34 |
35 | Edit 36 |
37 | {% endif %} 38 |
39 | New 40 |
41 |
42 | {% endif %} 43 | 44 | 45 | 46 |

{{ post.title }}

47 |
48 | {{post.rendered_text | safe}} 49 | 50 |
51 |
52 |
53 | {% if config.COMMENTS %} 54 |
55 |

Comments

56 |
57 |
58 | {% endif %} 59 | {% endblock main %} 60 | {% block extrajs %} 61 | {% if config.COMMENTS %} 62 | 63 | {% endif %} 64 | {% endblock extrajs %} 65 | -------------------------------------------------------------------------------- /app/templates/blogging/editor.html: -------------------------------------------------------------------------------- 1 | {% extends 'blogging/base.html' %} 2 | {% block extrastyle %} 3 | 4 | {% endblock extrastyle %} 5 | {% block main %} 6 |
7 | 8 | {{ form.hidden_tag() }} 9 | 10 |
11 | 12 | {% if form.tags.type != "RadioField" %} 13 | Edit Homepage 14 | {% else %} 15 | Update Editor 16 | {% endif %} 17 | 18 |
19 | 22 |
23 | {{form.title(placeholder="Title", id="title", class="form-control", required="" )}} 24 |
25 |
26 | 27 |
28 |
29 | {{ form.text }} 30 | 31 |

If you enter a fullscreen view, press ESC to return.

32 |

Learn more about MarkDown


33 | {% if config.BLOGGING_ALLOW_FILEUPLOAD %} 34 | Upload new File 35 | {% endif %} 36 |
37 |
38 |
39 | 40 |
41 | {% if form.tags.type == "RadioField" %} 42 | {% for subfield in form.tags %} 43 | 44 | {{ subfield }} 45 | {{ subfield.label }} 46 |
47 | 48 | {% endfor %} 49 | {% endif %} 50 |
51 | 52 | 59 |
60 |
61 | {{ form.submit(id="submit", class="btn btn-primary") }} 62 |
63 |
64 |
65 |
66 | {% endblock main %} 67 | 68 | {% block extrajs %} 69 | 70 | 73 | {% endblock extrajs %} 74 | -------------------------------------------------------------------------------- /app/admin_views/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, SubmitField, IntegerField, \ 3 | PasswordField, SelectField 4 | from wtforms.validators import DataRequired 5 | 6 | 7 | class BTCCodeForm(FlaskForm): 8 | host = StringField( 9 | 'URL of BTCPay Instance', 10 | validators=[DataRequired()] 11 | ) 12 | code = StringField('Pairing Code', validators=[DataRequired()]) 13 | submit = SubmitField('Submit') 14 | 15 | 16 | class GAForm(FlaskForm): 17 | code = StringField( 18 | 'Enter Tracking Code (begins with "UA")', 19 | validators=[DataRequired()] 20 | ) 21 | submit = SubmitField('Submit') 22 | 23 | 24 | class ThemeForm(FlaskForm): 25 | theme = SelectField( 26 | 'Select a theme:', 27 | choices=[ 28 | ('cerulean', 'Cerulean'), 29 | ('cosmo', 'Cosmo'), 30 | ('flatly', 'Flatly'), 31 | ('journal', 'Journal'), 32 | ('litera', 'Litera'), 33 | ('lumen', 'Lumen'), 34 | ('lux', 'Lux'), 35 | ('materia', 'Materia'), 36 | ('minty', 'Minty'), 37 | ('pulse', 'Pulse'), 38 | ('sandstone', 'Sandstone'), 39 | ('simplex', 'Simplex'), 40 | ('sketchy', 'Sketchy'), 41 | ('spacelab', 'Spacelab'), 42 | ('united', 'United'), 43 | ('yeti', 'Yeti'), 44 | ] 45 | ) 46 | submit = SubmitField('Submit') 47 | 48 | 49 | class IssoForm(FlaskForm): 50 | code = StringField( 51 | 'Enter a comment moderation password:', 52 | validators=[DataRequired()] 53 | ) 54 | submit = SubmitField('Activate User Comments') 55 | 56 | 57 | class SquareSetupForm(FlaskForm): 58 | application_id = StringField( 59 | 'Square Application ID', 60 | validators=[DataRequired()] 61 | ) 62 | location_id = StringField( 63 | 'Square Location ID', 64 | validators=[DataRequired()] 65 | ) 66 | access_token = StringField( 67 | 'Square Access Token', 68 | validators=[DataRequired()] 69 | ) 70 | submit = SubmitField('Submit') 71 | 72 | 73 | class EmailSetupForm(FlaskForm): 74 | default_sender = StringField( 75 | 'Enter the email to show on outbound emails:', 76 | validators=[DataRequired()] 77 | ) 78 | server = StringField( 79 | 'Enter Email Host Name', 80 | validators=[DataRequired()] 81 | ) 82 | port = IntegerField( 83 | 'Enter Server Port (normally 587)', 84 | validators=[DataRequired()] 85 | ) 86 | username = StringField( 87 | 'Enter email username', 88 | validators=[DataRequired()] 89 | ) 90 | password = PasswordField( 91 | 'Enter email password', 92 | validators=[DataRequired()] 93 | ) 94 | submit = SubmitField('Submit') 95 | -------------------------------------------------------------------------------- /flask_blogging_patron/templates/blogging/page.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 | 29 |
30 | {% if post.editable %} 31 | 32 |  Delete 33 |      34 | 35 | 36 |  Edit 37 |      38 | {% endif %} 39 | 40 |  New 41 | 42 |
43 | {% endif %} 44 | 45 | 46 | 47 |

{{ post.title }}

48 |
49 |

Posted by {{post.user_name}} 50 | on {{post.post_date.strftime('%d %b, %Y')}}

51 | {{post.rendered_text | safe}} 52 | 53 |
54 | 55 | {% if post.tags %} 56 |    57 | {% for tag in post.tags %} 58 | 59 | 60 | {{ tag }} 61 | 62 |    63 | {% endfor %} 64 |
65 | {% endif %} 66 |
67 | {% include "blogging/social_links.html" %} 68 |
69 | {% include "blogging/disqus.html" %} 70 | {% endblock main %} 71 | -------------------------------------------------------------------------------- /flask_blogging_patron/templates/blogging/editor.html: -------------------------------------------------------------------------------- 1 | {% extends 'blogging/base.html' %} 2 | {% block extrastyle %} 3 | 5 | {% endblock extrastyle %} 6 | {% block main %} 7 |
8 | 9 | {{ form.hidden_tag() }} 10 | 11 |
12 | 13 | Blog Editor 14 | 15 |
16 | 19 |
20 | {{form.title(placeholder="Title", id="title", class="form-control", required="" )}} 21 |
22 |
23 | 24 |
25 |
26 | {{form.text(placeholder="Blog text", required="", data_provide="markdown", rows="16")}} 27 | 28 | Learn more about MarkDown
29 | {% if config.BLOGGING_ALLOW_FILEUPLOAD %} 30 | Upload new File 31 | {% endif %} 32 |
33 |
34 |
35 | 36 |
37 | 38 | 39 |
40 | {{form.tags(id="tags", placeholder="Comma separated tags", class="form-control input-md", required="")}} 41 |
42 |
43 | 44 | 51 |
52 |
53 | {{ form.submit(id="submit", class="btn btn-primary") }} 54 |
55 |
56 |
57 |
58 | {% endblock main %} 59 | 60 | {% block extrajs %} 61 | 64 | 67 | {% endblock extrajs %} 68 | -------------------------------------------------------------------------------- /flask_blogging_patron/processor.py: -------------------------------------------------------------------------------- 1 | import re 2 | try: 3 | from builtins import object 4 | except ImportError: 5 | pass 6 | import markdown 7 | from markdown.extensions.meta import MetaExtension 8 | from flask import url_for 9 | from flask_login import current_user 10 | from slugify import slugify 11 | 12 | 13 | class MathJaxPattern(markdown.inlinepatterns.Pattern): 14 | 15 | def __init__(self): 16 | markdown.inlinepatterns.Pattern.__init__(self, 17 | r'(?]*src="([^"]+)') 48 | return regex.findall(post["rendered_text"]) 49 | 50 | @classmethod 51 | def construct_url(cls, post): 52 | url = url_for("blogging.page_by_id", post_id=post["post_id"], 53 | slug=cls.create_slug(post["title"])) 54 | return url 55 | 56 | @classmethod 57 | def render_text(cls, post): 58 | md = markdown.Markdown(extensions=cls.all_extensions()) 59 | post["rendered_text"] = md.convert(post["text"]) 60 | post["meta"] = md.Meta 61 | 62 | @classmethod 63 | def is_author(cls, post, user): 64 | return user.get_id() == u''+str(post['user_id']) 65 | 66 | @classmethod 67 | def process(cls, post, render=True): 68 | """ 69 | This method takes the post data and renders it 70 | :param post: 71 | :param render: 72 | :return: 73 | """ 74 | post["slug"] = cls.create_slug(post["title"]) 75 | post["editable"] = cls.is_author(post, current_user) 76 | post["url"] = cls.construct_url(post) 77 | post["priority"] = 0.8 78 | if render: 79 | cls.render_text(post) 80 | post["meta"]["images"] = cls.extract_images(post) 81 | 82 | @classmethod 83 | def all_extensions(cls): 84 | return cls._markdown_extensions 85 | 86 | @classmethod 87 | def set_custom_extensions(cls, extensions): 88 | if type(extensions) == list: 89 | cls._markdown_extensions.extend(extensions) 90 | -------------------------------------------------------------------------------- /app/static/sqpaymentform-basic.css: -------------------------------------------------------------------------------- 1 | * { 2 | -webkit-font-smoothing: antialiased; 3 | -moz-osx-font-smoothing: grayscale; 4 | } 5 | 6 | body, html { 7 | color: #373F4A; 8 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 9 | font-weight: normal; 10 | padding-top: 150px 11 | } 12 | 13 | iframe { 14 | margin: 0; 15 | padding-top: 0; 16 | border: 0; 17 | } 18 | 19 | button { 20 | border: 0; 21 | } 22 | 23 | hr { 24 | height: 1px; 25 | border: 0; 26 | background-color: #CCC; 27 | } 28 | 29 | fieldset { 30 | margin: 0; 31 | padding: 0; 32 | border: 0; 33 | } 34 | 35 | 36 | #form-container { 37 | position: relative; 38 | width: 380px; 39 | margin: 0 auto; 40 | top: 50%; 41 | transform: translateY(-50%); 42 | } 43 | 44 | .label { 45 | font-size: 14px; 46 | font-weight: 500; 47 | line-height: 24px; 48 | letter-spacing: 0.5; 49 | text-transform: uppercase; 50 | } 51 | 52 | .third { 53 | float: left; 54 | width: calc((100% - 32px) / 3); 55 | padding: 0; 56 | margin: 0 16px 16px 0; 57 | } 58 | 59 | .third:last-of-type { 60 | margin-right: 0; 61 | } 62 | 63 | /* Define how SqPaymentForm iframes should look */ 64 | .sq-input { 65 | box-sizing: border-box; 66 | border: 1px solid #E0E2E3; 67 | border-radius: 4px; 68 | outline-offset: -2px; 69 | display: inline-block; 70 | -webkit-transition: border-color .2s ease-in-out, background .2s ease-in-out; 71 | -moz-transition: border-color .2s ease-in-out, background .2s ease-in-out; 72 | -ms-transition: border-color .2s ease-in-out, background .2s ease-in-out; 73 | transition: border-color .2s ease-in-out, background .2s ease-in-out; 74 | } 75 | 76 | /* Define how SqPaymentForm iframes should look when they have focus */ 77 | .sq-input--focus { 78 | border: 1px solid #4A90E2; 79 | background-color: rgba(74,144,226,0.02); 80 | } 81 | 82 | 83 | /* Define how SqPaymentForm iframes should look when they contain invalid values */ 84 | .sq-input--error { 85 | border: 1px solid #E02F2F; 86 | background-color: rgba(244,47,47,0.02); 87 | } 88 | 89 | #sq-card-number { 90 | margin-bottom: 16px; 91 | } 92 | 93 | /* Customize the "Pay with Credit Card" button */ 94 | .button-credit-card { 95 | width: 100%; 96 | height: 56px; 97 | margin-top: 10px; 98 | background: #4A90E2; 99 | border-radius: 4px; 100 | cursor: pointer; 101 | display: block; 102 | color: #FFFFFF; 103 | font-size: 16px; 104 | line-height: 24px; 105 | font-weight: 700; 106 | letter-spacing: 0; 107 | text-align: center; 108 | -webkit-transition: background .2s ease-in-out; 109 | -moz-transition: background .2s ease-in-out; 110 | -ms-transition: background .2s ease-in-out; 111 | transition: background .2s ease-in-out; 112 | } 113 | 114 | .button-credit-card:hover { 115 | background-color: #4281CB; 116 | } 117 | 118 | 119 | #error { 120 | width: 100%; 121 | margin-top: 16px; 122 | font-size: 14px; 123 | color: red; 124 | font-weight: 500; 125 | text-align: center; 126 | opacity: 0.8; 127 | } 128 | -------------------------------------------------------------------------------- /flask_blogging_patron/templates/fileupload/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block style %} 6 | 7 | 10 | {% endblock style %} 11 | {% block extrastyle %} 12 | {% endblock extrastyle %} 13 | 14 | {% block title %} 15 | {% endblock title %} 16 | 17 | 18 | 19 | 20 | 21 | 35 | 36 | 37 |
38 | {% block message %} 39 | {% with messages = get_flashed_messages(with_categories=true) %} 40 | {% if messages %} 41 | {% for category, message in messages %} 42 |
43 |
44 | 50 |
51 |
52 | {% endfor %} 53 | {% endif %} 54 | {% endwith %} 55 | 56 | {% endblock %} 57 | {% block main %} 58 | {% endblock main %} 59 | {% block extramain %} 60 | {% endblock extramain %} 61 |
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 | 37 | 38 | 39 |
40 | {% block message %} 41 | {% with messages = get_flashed_messages(with_categories=true) %} 42 | {% if messages %} 43 | {% for category, message in messages %} 44 |
45 |
46 | 52 |
53 |
54 | {% endfor %} 55 | {% endif %} 56 | {% endwith %} 57 | 58 | {% endblock %} 59 | {% block main %} 60 | {% endblock main %} 61 | {% block extramain %} 62 | {% endblock extramain %} 63 |
64 | {% include "blogging/analytics.html" %} 65 | {% block js %} 66 | 67 | 68 | 69 | 78 | 79 | 86 | 87 | {% endblock js %} 88 | {% block extrajs %} 89 | {% endblock extrajs %} 90 | 91 | 92 | -------------------------------------------------------------------------------- /app/templates/blogging/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% include "blogging/analytics.html" %} 7 | {% block meta %} 8 | {% endblock meta %} 9 | {% block style %} 10 | 11 | 12 | 13 | {% endblock style %} 14 | {% block extrastyle %} 15 | {% endblock extrastyle %} 16 | 17 | {% block title %} 18 | {% endblock title %} 19 | 20 | 21 | 22 | 23 |
47 | 48 | 49 | 50 |
51 | {% block message %} 52 | {% with messages = get_flashed_messages(with_categories=true) %} 53 | {% if messages %} 54 | {% for category, message in messages %} 55 |
56 |
57 | 63 |
64 |
65 | {% endfor %} 66 | {% endif %} 67 | {% endwith %} 68 | 69 | {% endblock %} 70 | {% block main %} 71 | {% endblock main %} 72 | {% block extramain %} 73 | {% endblock extramain %} 74 |
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 | --------------------------------------------------------------------------------