/", view=article_detail, name="article"),
26 | ]
--------------------------------------------------------------------------------
/paywalled/templates/registration/password_reset_confirm.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% load crispy_forms_filters i18n %}
4 |
5 | {% block title %}{% trans "Password reset" %} · Parsifal{% endblock %}
6 |
7 | {% block content %}
8 | {% if validlink %}
9 |
10 |
11 |
12 |
13 |
{% trans "Confirm password reset" %}
14 |
15 |
16 |
21 |
22 |
23 |
24 |
25 | {% else %}
26 | {% trans "Invalid password reset link." %}
27 | {% endif %}
28 | {% endblock content %}
29 |
--------------------------------------------------------------------------------
/paywalled/templates/bamby_forms/layout/inline_field.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_field %}
2 |
3 | {% if field.is_hidden %}
4 | {{ field }}
5 | {% else %}
6 | {% if field|is_checkbox %}
7 |
8 |
9 | {% crispy_field field 'class' 'form-check-input' %}
10 | {{ field.label|safe }}
11 |
12 |
13 | {% else %}
14 |
15 |
16 | {{ field.label|safe }}
17 |
18 | {% crispy_field field 'placeholder' field.label %}
19 |
20 | {% endif %}
21 | {% endif %}
22 |
--------------------------------------------------------------------------------
/paywalled/templates/includes/footer.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | © 2021 Simple Complex
6 |
7 |
12 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/paywalled/templates/blog/publish_article.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load static i18n %}
3 | {% load crispy_forms_tags %}
4 |
5 | {% block head %}
6 | {% endblock head %}
7 |
8 | {% block content %}
9 |
10 |
14 |
15 | {% endblock content %}
16 |
17 | {% block javascript %}
18 |
19 |
20 |
36 | {% endblock javascript %}
--------------------------------------------------------------------------------
/paywalled/static/js/copy.js:
--------------------------------------------------------------------------------
1 | //the helper function
2 | let createCopy = function (textToCopy, triggerElementId, callback = null) {
3 | //add event listner to elementtrigger
4 | let trigger = document.getElementById(triggerElementId);
5 | trigger.addEventListener("click", function () {
6 | //create the readonly textarea with the text in it and hide it
7 | let tarea = document.createElement("textarea");
8 | tarea.setAttribute("id", triggerElementId + "-copyarea");
9 | tarea.setAttribute("readonly", "readonly");
10 | tarea.setAttribute(
11 | "style",
12 | "opacity: 0; position: absolute; z-index: -1; top: 0; left: -9999px;"
13 | );
14 | tarea.appendChild(document.createTextNode(textToCopy));
15 | document.body.appendChild(tarea);
16 |
17 | //select and copy the text in the readonly text area
18 | tarea.select();
19 | document.execCommand("copy");
20 |
21 | //remove the element from the DOM
22 | document.body.removeChild(tarea);
23 |
24 | //fire callback function if provided
25 | if (typeof callback === "function" && callback()) {
26 | callback();
27 | }
28 | });
29 | };
--------------------------------------------------------------------------------
/paywalled/templates/bamby_forms/layout/field_with_buttons.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_field %}
2 |
3 |
23 |
--------------------------------------------------------------------------------
/apps/authentication/backends.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from django.contrib.auth.backends import ModelBackend
3 |
4 |
5 | class CaseInsensitiveUsernameOrEmailModelBackend(ModelBackend):
6 | def authenticate(self, request, username=None, password=None, **kwargs):
7 | UserModel = get_user_model()
8 | if username is None:
9 | username = kwargs.get(UserModel.USERNAME_FIELD)
10 | try:
11 | try:
12 | case_insensitive_username_field = "{}__iexact".format(UserModel.USERNAME_FIELD)
13 | user = UserModel._default_manager.get(**{case_insensitive_username_field: username})
14 | except UserModel.DoesNotExist:
15 | case_insensitive_email_field = "{}__iexact".format(UserModel.EMAIL_FIELD)
16 | user = UserModel._default_manager.get(**{case_insensitive_email_field: username})
17 | except UserModel.DoesNotExist:
18 | # Run the default password hasher once to reduce the timing
19 | # difference between an existing and a non-existing user (#20760).
20 | UserModel().set_password(password)
21 | else:
22 | if user.check_password(password) and self.user_can_authenticate(user):
23 | return user
--------------------------------------------------------------------------------
/paywalled/templates/blog/create_article.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load static i18n %}
3 | {% load crispy_forms_tags %}
4 |
5 | {% block head %}
6 | {% endblock head %}
7 |
8 | {% block content %}
9 |
10 |
27 | {{ form.media }}
28 |
29 | {% endblock content %}
30 |
31 | {% block modal %}
32 |
33 | {% endblock modal %}
--------------------------------------------------------------------------------
/apps/authentication/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.7 on 2022-04-12 17:45
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | initial = True
11 |
12 | dependencies = [
13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14 | ]
15 |
16 | operations = [
17 | migrations.CreateModel(
18 | name='Profile',
19 | fields=[
20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21 | ('public_email', models.EmailField(blank=True, max_length=254, verbose_name='public email')),
22 | ('location', models.CharField(blank=True, max_length=50, verbose_name='location')),
23 | ('url', models.CharField(blank=True, max_length=50, verbose_name='url')),
24 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')),
25 | ],
26 | options={
27 | 'verbose_name': 'profile',
28 | 'verbose_name_plural': 'profiles',
29 | 'db_table': 'auth_profile',
30 | },
31 | ),
32 | ]
33 |
--------------------------------------------------------------------------------
/apps/authentication/models.py:
--------------------------------------------------------------------------------
1 | import os.path
2 |
3 | from django.conf import settings as django_settings
4 | from django.contrib.auth.models import User
5 | from django.db import models
6 | from django.db.models.signals import post_save
7 | from django.utils.translation import gettext_lazy as _
8 |
9 |
10 | class Profile(models.Model):
11 | user = models.OneToOneField(User, on_delete=models.CASCADE, verbose_name=_("user"))
12 | node_pubkey = models.CharField(max_length=80, unique=True, blank=True, null=True)
13 |
14 | class Meta:
15 | verbose_name = _("profile")
16 | verbose_name_plural = _("profiles")
17 | db_table = "auth_profile"
18 |
19 | def __str__(self):
20 | return self.get_screen_name()
21 |
22 | def get_screen_name(self):
23 | try:
24 | if self.user.get_full_name():
25 | return self.user.get_full_name()
26 | else:
27 | return self.user.username
28 | except Exception:
29 | return self.user.username
30 |
31 |
32 | def create_user_profile(sender, instance, created, **kwargs):
33 | if created:
34 | Profile.objects.create(user=instance)
35 |
36 |
37 | def save_user_profile(sender, instance, **kwargs):
38 | instance.profile.save()
39 |
40 |
41 | post_save.connect(create_user_profile, sender=User)
42 | post_save.connect(save_user_profile, sender=User)
--------------------------------------------------------------------------------
/apps/accounts/views.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 |
4 | from django.contrib.auth.decorators import login_required
5 | from django.contrib.auth.mixins import LoginRequiredMixin
6 | from django.contrib.messages.views import SuccessMessageMixin
7 | from django.urls import reverse_lazy
8 | from django.utils.translation import gettext, gettext_lazy as _
9 | from django.views.generic import RedirectView, UpdateView
10 |
11 | from apps.accounts.forms import ProfileForm, UserEmailForm
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | class SettingsRedirectView(LoginRequiredMixin, RedirectView):
17 | pattern_name = "settings:profile"
18 |
19 |
20 | class UpdateProfileView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
21 | form_class = ProfileForm
22 | success_url = reverse_lazy("settings:profile")
23 | success_message = _("Your profile was updated with success!")
24 | template_name = "accounts/profile.html"
25 |
26 | def get_object(self, queryset=None):
27 | return self.request.user.profile
28 |
29 |
30 | class UpdateEmailsView(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
31 | form_class = UserEmailForm
32 | success_url = reverse_lazy("settings:emails")
33 | success_message = _("Account email was updated with success!")
34 | template_name = "accounts/emails.html"
35 |
36 | def get_object(self, queryset=None):
37 | return self.request.user
38 |
--------------------------------------------------------------------------------
/paywalled/templates/partials/article_list.html:
--------------------------------------------------------------------------------
1 | {% load humanize %}
2 |
3 |
4 |
5 |
10 |
11 |
14 |
Updated
15 | {{ article.date_published|naturaltime }}
16 | . Total paid views: {{ article.get_view_count }}. Total reward:
17 | {{ article.get_total_reward.total_reward }} sats
18 |
19 | {% if request.user == article.user %}
20 |
25 | {% endif %}
26 |
27 |
28 |
--------------------------------------------------------------------------------
/apps/authentication/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.contrib.auth.forms import UserCreationForm, UsernameField
3 | from django.utils.translation import gettext
4 |
5 | from apps.authentication.validators import (
6 | ASCIIUsernameValidator,
7 | validate_case_insensitive_email,
8 | validate_case_insensitive_username,
9 | validate_forbidden_usernames,
10 | )
11 |
12 |
13 | class ASCIIUsernameField(UsernameField):
14 | def __init__(self, *args, **kwargs):
15 | super().__init__(*args, **kwargs)
16 | self.validators.append(ASCIIUsernameValidator())
17 | self.validators.append(validate_forbidden_usernames)
18 | self.validators.append(validate_case_insensitive_username)
19 |
20 |
21 | class SignUpForm(UserCreationForm):
22 |
23 | class Meta(UserCreationForm.Meta):
24 | fields = ("username", "email")
25 | field_classes = {"username": ASCIIUsernameField}
26 |
27 | def __init__(self, *args, **kwargs):
28 | self.request = kwargs.pop("request", None)
29 | super().__init__(*args, **kwargs)
30 | self.fields["username"].help_text = gettext("Required. 150 characters or fewer. Letters, digits and . _ only.")
31 | self.fields["email"].validators.append(validate_case_insensitive_email)
32 | self.fields["email"].required = True
33 |
34 | def clean(self):
35 | cleaned_data = super().clean()
36 | return cleaned_data
37 |
38 | def save(self, commit=True):
39 | user = super().save(commit=commit)
40 | return user
--------------------------------------------------------------------------------
/apps/authentication/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.contrib.auth import login
3 | from django.core.exceptions import ValidationError
4 | from django.shortcuts import redirect
5 | from django.utils.decorators import method_decorator
6 | from django.utils.functional import cached_property
7 | from django.utils.translation import gettext as _
8 | from django.views.decorators.cache import never_cache
9 | from django.views.decorators.csrf import csrf_protect
10 | from django.views.decorators.debug import sensitive_post_parameters
11 | from django.views.generic import FormView
12 |
13 | from apps.authentication.forms import SignUpForm
14 |
15 |
16 | @method_decorator([sensitive_post_parameters(), csrf_protect, never_cache], name="dispatch")
17 | class SignUpView(FormView):
18 | form_class = SignUpForm
19 | template_name = "registration/signup.html"
20 |
21 | def get_form_kwargs(self):
22 | kwargs = super().get_form_kwargs()
23 | kwargs.update(request=self.request)
24 | return kwargs
25 |
26 | def form_valid(self, form):
27 | user = form.save()
28 | login(self.request, user)
29 | messages.success(self.request, _("Your account was successfully created."))
30 | return redirect(user)
31 |
32 | def form_invalid(self, form):
33 | messages.error(
34 | self.request,
35 | _(
36 | "There were some problems while creating your account. "
37 | "Please review the form below before submitting it again."
38 | ),
39 | )
40 | return super().form_invalid(form)
41 |
42 | def get_context_data(self, **kwargs):
43 | return super().get_context_data(**kwargs)
--------------------------------------------------------------------------------
/apps/payments/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.7 on 2022-04-13 23:04
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | initial = True
11 |
12 | dependencies = [
13 | ('blog', '0004_article_uuid'),
14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15 | ]
16 |
17 | operations = [
18 | migrations.CreateModel(
19 | name='Payment',
20 | fields=[
21 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22 | ('purpose', models.CharField(choices=[('publish', 'Publish'), ('view', 'View'), ('edit', 'Edit'), ('comment', 'Comment')], max_length=10)),
23 | ('satoshi_amount', models.IntegerField()),
24 | ('r_hash', models.CharField(max_length=64)),
25 | ('payment_request', models.CharField(max_length=1000)),
26 | ('status', models.CharField(choices=[('pending_invoice', 'Pending Invoice'), ('pending_payment', 'Pending Payment'), ('complete', 'Complete'), ('error', 'Error')], default='pending_invoice', max_length=50)),
27 | ('created_at', models.DateTimeField(auto_now_add=True)),
28 | ('modified_at', models.DateTimeField(auto_now=True)),
29 | ('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='blog.article')),
30 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments', to=settings.AUTH_USER_MODEL)),
31 | ],
32 | ),
33 | ]
34 |
--------------------------------------------------------------------------------
/apps/blog/migrations/0002_auto_20220412_2227.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.7 on 2022-04-12 19:27
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 | import markdownx.models
7 |
8 |
9 | class Migration(migrations.Migration):
10 |
11 | dependencies = [
12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13 | ('blog', '0001_initial'),
14 | ]
15 |
16 | operations = [
17 | migrations.CreateModel(
18 | name='Article',
19 | fields=[
20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21 | ('image', models.ImageField(upload_to='articles_pictures/%Y/%m/%d/', verbose_name='Featured image')),
22 | ('timestamp', models.DateTimeField(auto_now_add=True)),
23 | ('title', models.CharField(max_length=255, unique=True)),
24 | ('slug', models.SlugField(blank=True, max_length=80, null=True)),
25 | ('status', models.CharField(choices=[('D', 'Draft'), ('P', 'Published')], default='D', max_length=1)),
26 | ('content', markdownx.models.MarkdownxField()),
27 | ('edited', models.BooleanField(default=False)),
28 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='author', to=settings.AUTH_USER_MODEL)),
29 | ],
30 | options={
31 | 'verbose_name': 'Article',
32 | 'verbose_name_plural': 'Articles',
33 | 'ordering': ('-timestamp',),
34 | },
35 | ),
36 | migrations.DeleteModel(
37 | name='Entry',
38 | ),
39 | ]
40 |
--------------------------------------------------------------------------------
/apps/blog/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.7 on 2022-04-12 17:45
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | initial = True
11 |
12 | dependencies = [
13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14 | ]
15 |
16 | operations = [
17 | migrations.CreateModel(
18 | name='Entry',
19 | fields=[
20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21 | ('title', models.CharField(max_length=255)),
22 | ('slug', models.SlugField(blank=True, max_length=255, null=True)),
23 | ('content', models.TextField(blank=True, max_length=4000, null=True)),
24 | ('summary', models.TextField(blank=True, max_length=255, null=True)),
25 | ('status', models.CharField(choices=[('D', 'Draft'), ('H', 'Hidden'), ('P', 'Published')], max_length=10)),
26 | ('start_publication', models.DateTimeField()),
27 | ('creation_date', models.DateTimeField(auto_now_add=True)),
28 | ('last_update', models.DateTimeField(auto_now=True)),
29 | ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
30 | ('edited_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
31 | ],
32 | options={
33 | 'verbose_name': 'entry',
34 | 'verbose_name_plural': 'entries',
35 | },
36 | ),
37 | ]
38 |
--------------------------------------------------------------------------------
/paywalled/templates/bamby_forms/layout/radioselect.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_filters %}
2 | {% load l10n %}
3 |
4 |
5 |
6 | {% for group, options, index in field|optgroups %}
7 | {% if group %}
{{ group }} {% endif %}
8 | {% for option in options %}
9 |
11 |
15 |
17 | {{ option.label|unlocalize }}
18 |
19 | {% if field.errors and forloop.last and not inline_class and forloop.parentloop.last %}
20 | {% include 'bamby_forms/layout/field_errors_block.html' %}
21 | {% endif %}
22 |
23 | {% endfor %}
24 | {% endfor %}
25 | {% if field.errors and inline_class %}
26 |
28 | {# the following input is only meant to allow boostrap to render the error message as it has to be after an invalid input. As the input has no name, no data will be sent. #}
29 |
30 | {% include 'bamby_forms/layout/field_errors_block.html' %}
31 |
32 | {% endif %}
33 |
34 | {% include 'bamby_forms/layout/help_text.html' %}
35 |
36 |
--------------------------------------------------------------------------------
/paywalled/templates/bamby_forms/table_inline_formset.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_tags %}
2 | {% load crispy_forms_utils %}
3 | {% load crispy_forms_field %}
4 |
5 | {% specialspaceless %}
6 | {% if formset_tag %}
7 | {% endif %}
60 | {% endspecialspaceless %}
61 |
--------------------------------------------------------------------------------
/paywalled/templates/bamby_forms/layout/checkboxselectmultiple.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_filters %}
2 | {% load l10n %}
3 |
4 |
5 |
6 | {% for group, options, index in field|optgroups %}
7 | {% if group %}
{{ group }} {% endif %}
8 | {% for option in options %}
9 |
11 |
15 |
17 | {{ option.label|unlocalize }}
18 |
19 | {% if field.errors and forloop.last and not inline_class and forloop.parentloop.last %}
20 | {% include 'bamby_forms/layout/field_errors_block.html' %}
21 | {% endif %}
22 |
23 | {% endfor %}
24 | {% endfor %}
25 | {% if field.errors and inline_class %}
26 |
28 | {# the following input is only meant to allow boostrap to render the error message as it has to be after an invalid input. As the input has no name, no data will be sent. #}
29 |
30 | {% include 'bamby_forms/layout/field_errors_block.html' %}
31 |
32 | {% endif %}
33 |
34 | {% include 'bamby_forms/layout/help_text.html' %}
35 |
36 |
--------------------------------------------------------------------------------
/paywalled/templates/bamby_forms/layout/prepended_appended_text.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_field %}
2 |
3 | {% if field.is_hidden %}
4 | {{ field }}
5 | {% else %}
6 |
46 | {% endif %}
47 |
--------------------------------------------------------------------------------
/apps/accounts/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from django.contrib.auth.models import User
3 | from django.core.exceptions import ValidationError
4 | from django.db import transaction
5 | from django.utils.translation import gettext, gettext_lazy as _
6 |
7 | from apps.authentication.models import Profile
8 |
9 |
10 | class UserEmailForm(forms.ModelForm):
11 | email = forms.CharField(
12 | label=_("Email"),
13 | widget=forms.EmailInput(attrs={"class": "form-control"}),
14 | max_length=254,
15 | help_text=_(
16 | "This email account will not be publicly available. "
17 | "It is used for your Parsifal account management, "
18 | "such as internal notifications and password reset."
19 | ),
20 | )
21 |
22 | class Meta:
23 | model = User
24 | fields = ("email",)
25 |
26 | def clean_email(self):
27 | email = self.cleaned_data.get("email")
28 | email = User.objects.normalize_email(email)
29 | if User.objects.exclude(pk=self.instance.pk).filter(email__iexact=email).exists():
30 | raise ValidationError(gettext("User with this Email already exists."))
31 | return email
32 |
33 |
34 | class ProfileForm(forms.ModelForm):
35 | first_name = forms.CharField(label=_("First name"), max_length=150, required=False)
36 | last_name = forms.CharField(label=_("Last name"), max_length=150, required=False)
37 |
38 | def __init__(self, *args, **kwargs):
39 | super().__init__(*args, **kwargs)
40 | self.fields["first_name"].initial = self.instance.user.first_name
41 | self.fields["last_name"].initial = self.instance.user.last_name
42 |
43 | class Meta:
44 | model = Profile
45 | fields = ("first_name", "last_name", "node_pubkey")
46 |
47 | @transaction.atomic()
48 | def save(self, commit=True):
49 | self.instance.user.first_name = self.cleaned_data["first_name"]
50 | self.instance.user.last_name = self.cleaned_data["last_name"]
51 | if commit:
52 | self.instance.user.save()
53 | return super().save(commit)
--------------------------------------------------------------------------------
/apps/payments/views.py:
--------------------------------------------------------------------------------
1 | import re
2 | from django.shortcuts import render, get_object_or_404
3 | from apps.blog.models import Article
4 | from django.views.decorators.http import require_http_methods, require_GET
5 | from django.http import HttpRequest, HttpResponse
6 | from django.conf import settings
7 | from django_htmx.http import HttpResponseStopPolling
8 |
9 | from apps.payments.models import Payment
10 |
11 | import codecs
12 | from lnd_grpc import lnd_grpc
13 |
14 | import lnd_grpc.protos.rpc_pb2 as ln
15 |
16 | lnrpc = lnd_grpc.Client(
17 | lnd_dir = settings.LND_FOLDER,
18 | macaroon_path = settings.LND_MACAROON_FILE,
19 | tls_cert_path = settings.LND_TLS_CERT_FILE,
20 | network = settings.LND_NETWORK,
21 | )
22 |
23 |
24 | # Create your views here.
25 |
26 | def check_payment(request, pk):
27 | """
28 | Checks if the Lightning payment has been received for this invoice
29 | """
30 | # get the payment in question
31 | payment = Payment.objects.get(pk=pk)
32 |
33 | r_hash_base64 = payment.r_hash.encode('utf-8')
34 | r_hash_bytes = codecs.decode(r_hash_base64, 'base64')
35 | invoice_resp = lnrpc.lookup_invoice(r_hash=r_hash_bytes)
36 |
37 |
38 | if request.htmx:
39 | if invoice_resp.settled:
40 | # create session key
41 | if not request.session.session_key:
42 | request.session.create()
43 | # Payment complete
44 | payment.status = 'complete'
45 | if request.user.is_authenticated:
46 | payment.user = request.user
47 | payment.session_key = request.session.session_key
48 | else:
49 | # if user is anon, save in session
50 | payment.session_key = request.session.session_key
51 | payment.save()
52 | return HttpResponseStopPolling("Payment confirmed. Thank you
")
53 | else:
54 | # Payment not received
55 | return HttpResponse("Invoice payment is still pending. Will check again in 10s
")
56 |
57 |
--------------------------------------------------------------------------------
/apps/blog/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 | from crispy_forms.helper import FormHelper
3 | from crispy_forms.layout import Layout, Row, Column, ButtonHolder, Submit, HTML, Field, Button
4 | from tinymce.widgets import TinyMCE
5 | from apps.blog.models import Article
6 |
7 |
8 | class ArticleForm(forms.ModelForm):
9 | status = forms.CharField(widget=forms.HiddenInput())
10 | edited = forms.BooleanField(
11 | widget=forms.HiddenInput(), required=False, initial=False
12 | )
13 | title = forms.CharField(
14 | widget=forms.TextInput(attrs={"class": "form-control form-control-lg", "placeholder": "Article title"}),
15 | max_length=400,
16 | required=True,
17 | label="Title of your masterpiece",
18 | )
19 | content = forms.CharField(
20 | widget=TinyMCE(attrs={'cols': 80, 'rows': 30}),
21 | required=False,
22 | )
23 |
24 | def __init__(self, *args, **kwargs):
25 | super().__init__(*args, **kwargs)
26 | self.helper = FormHelper(self)
27 | self.helper.layout = Layout(
28 | "title",
29 | "content",
30 | "status",
31 | "edited",
32 | HTML(
33 | """
34 | {% if payment_made %}
35 | Payment confirmed. Thank you
36 | {% else %}
37 |
38 | {% if invoice %}
39 | {% include 'partials/partial_invoice.html' with purpose='publish' %}
40 | {% endif %}
41 |
42 | {% endif %}
43 | """
44 | ),
45 | ButtonHolder(
46 | Submit("publish", "Publish article", css_class="publish btn btn-lg btn-success mr-2"),
47 | Submit("save", "Save as draft", css_class="draft btn btn-lg btn-subtle-primary mr-2"),
48 | HTML(
49 | """
50 | Cancel
51 | """
52 | )
53 | ),
54 | )
55 |
56 | class Meta:
57 | model = Article
58 | fields = ["title", "content", "status", "edited"]
--------------------------------------------------------------------------------
/paywalled/urls.py:
--------------------------------------------------------------------------------
1 | """paywalled URL Configuration
2 | """
3 | from django.contrib import admin
4 | from django.urls import path, include
5 | from django.conf.urls import url
6 | from django.views.generic import RedirectView, TemplateView
7 | from django.conf.urls.static import static
8 | from django.conf import settings
9 | from django.apps import apps
10 | from apps.authentication import views as auth_views
11 | from apps.blog import views as blog_views
12 | from apps.core import views as core_views
13 | from django.contrib.sitemaps.views import sitemap
14 | from django.views import defaults as default_views
15 |
16 | urlpatterns = [
17 | path("", core_views.home, name="home"),
18 | path("", include("django.contrib.auth.urls")),
19 | path("login/success/", core_views.LoginRedirectView.as_view(), name="login_redirect"),
20 | path("signup/", auth_views.SignUpView.as_view(), name="signup"),
21 | path("signin/", RedirectView.as_view(pattern_name="login"), name="signin"),
22 | path("articles/", include("apps.blog.urls", namespace="articles")),
23 | path("payments/", include("apps.payments.urls", namespace="payments")),
24 | path('admin/', admin.site.urls),
25 | path("sitemap.xml", sitemap, name="sitemap"),
26 | path("robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain")),
27 | path("settings/", include("apps.accounts.urls", namespace="settings")),
28 | path('tinymce/', include('tinymce.urls')),
29 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
30 |
31 | if settings.DEBUG:
32 | # This allows the error pages to be debugged during development
33 | urlpatterns += [
34 | url(
35 | r"^400/$",
36 | default_views.bad_request,
37 | kwargs={"exception": Exception("Bad Request!")},
38 | ),
39 | url(
40 | r"^403/$",
41 | default_views.permission_denied,
42 | kwargs={"exception": Exception("Permission Denied")},
43 | ),
44 | url(
45 | r"^404/$",
46 | default_views.page_not_found,
47 | kwargs={"exception": Exception("Page not Found")},
48 | ),
49 | url(r"^500/$", default_views.server_error),
50 | ]
51 | if "debug_toolbar" in settings.INSTALLED_APPS:
52 | import debug_toolbar
53 |
54 | urlpatterns = [url(r"^__debug__/", include(debug_toolbar.urls))] + urlpatterns
--------------------------------------------------------------------------------
/paywalled/templates/includes/header.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 |
--------------------------------------------------------------------------------
/paywalled/templates/403.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Parsifal · 403
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
Parsifal
24 |
403Permission denied
25 |
We think you should not be here.
26 |
Home
27 |
·
28 |
Blog
29 |
·
30 |
Help
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | © 2021 Simple Complex
41 |
42 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/paywalled/templates/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Parsifal · 404
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
Parsifal
24 |
404Page not found
25 |
The requested url was not found on this server.
26 |
Home
27 |
·
28 |
Blog
29 |
·
30 |
Help
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | © 2021 Simple Complex
41 |
42 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/paywalled/templates/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Parsifal · 500
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
Parsifal
24 |
500Internal server error
25 |
Something went wrong. That's all we know.
26 |
Home
27 |
·
28 |
Blog
29 |
·
30 |
Help
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | © 2021 Simple Complex
41 |
42 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/apps/payments/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from django.conf import settings
3 | from apps.blog.models import Article
4 |
5 | import codecs
6 | from lnd_grpc import lnd_grpc
7 |
8 | from django.http import HttpRequest, HttpResponse
9 | from django.conf import settings
10 | from django.contrib.sessions.models import Session
11 |
12 | import lnd_grpc.protos.rpc_pb2 as ln
13 |
14 | lnrpc = lnd_grpc.Client(
15 | lnd_dir = settings.LND_FOLDER,
16 | macaroon_path = settings.LND_MACAROON_FILE,
17 | tls_cert_path = settings.LND_TLS_CERT_FILE,
18 | network = settings.LND_NETWORK,
19 | )
20 |
21 | # Create your models here.
22 |
23 | class Payment(models.Model):
24 |
25 | PAYMENT_STATUS_CHOICES = (
26 | ('pending_invoice', 'Pending Invoice'), # Should be atomic
27 | ('pending_payment', 'Pending Payment'),
28 | ('complete', 'Complete'),
29 | ('error', 'Error'),
30 | )
31 |
32 | PUBLISH = "publish"
33 | VIEW = "view"
34 | EDIT = "edit"
35 | COMMENT = "comment"
36 | PAYMENT_PURPOSE_CHOICES = (
37 | (PUBLISH, 'Publish'),
38 | (VIEW, 'View'),
39 | (EDIT, 'Edit'),
40 | (COMMENT, 'Comment'),
41 | )
42 |
43 | user = models.ForeignKey(
44 | settings.AUTH_USER_MODEL,
45 | null=True,
46 | related_name="payments",
47 | on_delete=models.SET_NULL,
48 | )
49 | session_key = models.CharField(max_length=40, blank=True, null=True)
50 | article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='payments')
51 | purpose = models.CharField(max_length=10, choices=PAYMENT_PURPOSE_CHOICES)
52 |
53 | satoshi_amount = models.IntegerField()
54 | r_hash = models.CharField(max_length=64)
55 | payment_request = models.CharField(max_length=1000)
56 |
57 | status = models.CharField(max_length=50, default='pending_invoice', choices=PAYMENT_STATUS_CHOICES)
58 | created_at = models.DateTimeField(auto_now_add=True)
59 | modified_at = models.DateTimeField(auto_now=True)
60 |
61 | def check_payment(self):
62 | """
63 | Checks if the Lightning payment has been received for this invoice
64 | """
65 | # if self.status == 'pending_payment':
66 | # return False
67 |
68 | r_hash_base64 = self.r_hash.encode('utf-8')
69 | r_hash_bytes = str(codecs.decode(r_hash_base64, 'base64'))
70 | invoice_resp = lnrpc.lookup_invoice(ln.PaymentHash(r_hash=r_hash_bytes))
71 |
72 | if invoice_resp.settled:
73 | # Payment complete
74 | self.status = 'complete'
75 | self.save()
76 | return HttpResponse("Invoice paid successfully")
77 | else:
78 | # Payment not received
79 | return HttpResponse("Invoice pending payment")
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### Python template
2 | # Byte-compiled / optimized / DLL files
3 | __pycache__/
4 | *.py[cod]
5 | *$py.class
6 |
7 | # C extensions
8 | *.so
9 |
10 | # Distribution / packaging
11 | .Python
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *.cover
47 | .hypothesis/
48 |
49 | # Django stuff:
50 | staticfiles/
51 |
52 | # pyenv
53 | .python-version
54 |
55 |
56 |
57 | # Environments
58 | .venv
59 | venv/
60 | ENV/
61 |
62 | # Rope project settings
63 | .ropeproject
64 |
65 | # mkdocs documentation
66 | /site
67 |
68 | # mypy
69 | .mypy_cache/
70 |
71 | # Coverage directory used by tools like istanbul
72 | coverage
73 |
74 | # Dependency directories
75 | node_modules/
76 |
77 | # Typescript v1 declaration files
78 | typings/
79 |
80 | # Optional npm cache directory
81 | .npm
82 |
83 | # Optional eslint cache
84 | .eslintcache
85 |
86 | # Optional REPL history
87 | .node_repl_history
88 |
89 | # Output of 'npm pack'
90 | *.tgz
91 |
92 | # Yarn Integrity file
93 | .yarn-integrity
94 |
95 |
96 | ### Linux template
97 | *~
98 |
99 | # temporary files which can be created if a process still has a handle open of a deleted file
100 | .fuse_hidden*
101 |
102 | # KDE directory preferences
103 | .directory
104 |
105 | # Linux trash folder which might appear on any partition or disk
106 | .Trash-*
107 |
108 | # .nfs files are created when an open file is removed but is still being accessed
109 | .nfs*
110 |
111 |
112 | ### VisualStudioCode template
113 | .vscode/*
114 | .vscode/settings.json
115 | !.vscode/tasks.json
116 | !.vscode/launch.json
117 | !.vscode/extensions.json
118 |
119 | ### macOS template
120 | # General
121 | *.DS_Store
122 | .AppleDouble
123 | .LSOverride
124 |
125 | # Icon must end with two \r
126 | Icon
127 |
128 | # Thumbnails
129 | ._*
130 |
131 | # Files that might appear in the root of a volume
132 | .DocumentRevisions-V100
133 | .fseventsd
134 | .Spotlight-V100
135 | .TemporaryItems
136 | .Trashes
137 | .VolumeIcon.icns
138 | .com.apple.timemachine.donotpresent
139 |
140 | ### Project template
141 |
142 | paywalled/media/
143 | paywalled/static/css/theme.css
144 | theme.css
145 |
146 | .pytest_cache/
147 |
148 |
149 | .ipython/
150 | .env
151 | .envs/*
152 | !.envs/.local/
153 | package-lock.json
--------------------------------------------------------------------------------
/apps/authentication/validators.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from django.contrib.auth.models import User
4 | from django.core import validators
5 | from django.core.exceptions import ValidationError
6 | from django.utils.deconstruct import deconstructible
7 | from django.utils.translation import gettext, gettext_lazy as _
8 |
9 |
10 | def validate_forbidden_usernames(value):
11 | forbidden_usernames = {
12 | "admin",
13 | "settings",
14 | "news",
15 | "about",
16 | "help",
17 | "signin",
18 | "signup",
19 | "signout",
20 | "terms",
21 | "privacy",
22 | "cookie",
23 | "new",
24 | "login",
25 | "logout",
26 | "administrator",
27 | "join",
28 | "account",
29 | "username",
30 | "root",
31 | "blog",
32 | "user",
33 | "users",
34 | "billing",
35 | "subscribe",
36 | "review",
37 | "blog",
38 | "blogs",
39 | "edit",
40 | "mail",
41 | "email",
42 | "home",
43 | "job",
44 | "jobs",
45 | "contribute",
46 | "newsletter",
47 | "shop",
48 | "profile",
49 | "register",
50 | "auth",
51 | "authentication",
52 | "campaign",
53 | "config",
54 | "delete",
55 | "remove",
56 | "forum",
57 | "forums",
58 | "download",
59 | "downloads",
60 | "contact",
61 | "blogs",
62 | "feed",
63 | "faq",
64 | "intranet",
65 | "log",
66 | "registration",
67 | "search",
68 | "explore",
69 | "rss",
70 | "support",
71 | "status",
72 | "static",
73 | "media",
74 | "setting",
75 | "css",
76 | "js",
77 | "follow",
78 | "activity",
79 | "library",
80 | "reset",
81 | "sitemap.xml",
82 | "robots.txt",
83 | "password_change",
84 | "password_reset",
85 | }
86 | if value.lower() in forbidden_usernames:
87 | raise ValidationError(gettext("This is a reserved word."))
88 |
89 |
90 | def validate_case_insensitive_email(value):
91 | if User.objects.filter(email__iexact=value).exists():
92 | raise ValidationError(gettext("A user with that email already exists."))
93 |
94 |
95 | def validate_case_insensitive_username(value):
96 | if User.objects.filter(username__iexact=value).exists():
97 | raise ValidationError(gettext("A user with that username already exists."))
98 |
99 |
100 | @deconstructible
101 | class ASCIIUsernameValidator(validators.RegexValidator):
102 | regex = r"^[\w.]+\Z"
103 | message = _("Enter a valid username. This value may contain only English letters, numbers, and . _ characters.")
104 | flags = re.ASCII
--------------------------------------------------------------------------------
/paywalled/templates/bamby_forms/layout/field_file.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_field %}
2 |
3 |
4 | {% for widget in field.subwidgets %}
5 | {% if widget.data.is_initial %}
6 |
26 |
55 |
56 | {% include 'bamby_forms/layout/help_text_and_errors.html' %}
57 |
58 | {% endif %}
59 | {% endfor %}
60 |
61 |
--------------------------------------------------------------------------------
/paywalled/templates/partials/partial_invoice.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 |
3 |
8 |
9 |
40 |
41 |
45 |
46 |
47 | {% block javascript %}
48 |
49 |
50 |
69 | {% endblock %}
--------------------------------------------------------------------------------
/paywalled/templates/bamby_forms/field.html:
--------------------------------------------------------------------------------
1 | {% load crispy_forms_field %}
2 |
3 | {% if field.is_hidden %}
4 | {{ field }}
5 | {% else %}
6 | {% if field|is_checkbox %}
7 |
63 | {% endif %}
64 | {% endif %}
65 |
--------------------------------------------------------------------------------
/paywalled/templates/base.html:
--------------------------------------------------------------------------------
1 | {% load compress static django_htmx %}
2 |
3 |
4 |
5 |
6 |
7 | {% block title %}Paywalled{% endblock %}
8 |
9 | {% block meta %}
10 |
11 |
12 |
13 |
14 | {% endblock %}
15 |
16 |
17 |
18 |
19 |
20 | {% compress css %}
21 |
22 |
23 | {% block stylesheet %}{% endblock %}
24 | {% endcompress %}
25 |
26 |
28 |
29 |
30 |
31 |
32 | {% block body %}
33 |
34 | {% block header %}
35 | {% include 'includes/header.html' %}
36 | {% endblock header %}
37 |
38 |
39 |
40 | {% block inner %}{% endblock inner %}
41 |
42 | {% include 'includes/messages.html' %}
43 | {% block content %}{% endblock %}
44 |
45 |
46 |
47 |
48 |
49 | {% endblock body %}
50 |
62 |
69 | {% compress js %}
70 |
73 |
76 | {% block javascript %}{% endblock %}
77 |
78 | {% endcompress %}
79 |
80 |
81 | {% django_htmx_script %}
82 |
83 |
84 | {% block modal %}{% endblock modal %}
85 |
86 |
--------------------------------------------------------------------------------
/apps/core/templates/core/home.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load static i18n humanize %}
3 |
4 | {% block content %}
5 |
6 |
7 |
8 |
9 |
10 |
11 |
Paywalled
12 |
Exploring the lightning network through
13 | micropayments. Pay a tiny fee to publish, read, edit and comment on articles.
14 |
15 |
16 |
17 |
18 |
19 |
20 | {% for article in articles %}
21 | {% include 'partials/article_list.html' %}
22 | {% empty %}
23 |
26 | {% endfor %}
27 |
28 |
29 |
30 | {% if is_paginated %}
31 |
53 | {% endif %}
54 |
55 |
56 |
57 |
58 |
59 |
61 |
62 | {% if payments %}
63 |
64 | {% for payment in payments %}
65 | {% include 'partials/article_payment.html' with mode="general" %}
66 | {% endfor %}
67 |
68 | {% else %}
69 |
No payments for this article at this time.
70 | {% endif %}
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | {% endblock content %}
--------------------------------------------------------------------------------
/apps/blog/models.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import User
2 | from django.db import models
3 | from django.urls import reverse
4 | from django.utils.translation import gettext_lazy as _
5 | from django.conf import settings
6 | from slugify import slugify
7 | from markdownx.models import MarkdownxField
8 | from markdownx.utils import markdownify
9 | import uuid
10 | from django.db.models import Sum
11 |
12 | import codecs
13 | from lnd_grpc import lnd_grpc
14 |
15 | lnrpc = lnd_grpc.Client(
16 | lnd_dir = settings.LND_FOLDER,
17 | macaroon_path = settings.LND_MACAROON_FILE,
18 | tls_cert_path = settings.LND_TLS_CERT_FILE,
19 | network = settings.LND_NETWORK,
20 | )
21 |
22 | class ArticleQuerySet(models.query.QuerySet):
23 | """Personalized queryset created to improve model usability"""
24 |
25 | def get_published(self):
26 | """Returns only the published items in the current queryset."""
27 | return self.filter(status="P").order_by("-date_published")
28 |
29 | def get_drafts(self):
30 | """Returns only the items marked as DRAFT in the current queryset."""
31 | return self.filter(status="D").order_by("-date_published")
32 |
33 |
34 | class Article(models.Model):
35 | DRAFT = "D"
36 | PUBLISHED = "P"
37 | STATUS = ((DRAFT, _("Draft")), (PUBLISHED, _("Published")))
38 |
39 | uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
40 | user = models.ForeignKey(
41 | settings.AUTH_USER_MODEL,
42 | null=True,
43 | related_name="articles",
44 | on_delete=models.SET_NULL,
45 | )
46 | date_created = models.DateTimeField(auto_now_add=True)
47 | date_published = models.DateTimeField(auto_now=True)
48 | title = models.CharField(max_length=255, null=True, unique=True)
49 | status = models.CharField(max_length=1, choices=STATUS, default=DRAFT)
50 | content = models.CharField(_("Content"), max_length=40000, blank=True)
51 | edited = models.BooleanField(default=False)
52 | objects = ArticleQuerySet.as_manager()
53 |
54 | class Meta:
55 | verbose_name = _("Article")
56 | verbose_name_plural = _("Articles")
57 | ordering = ("-date_published",)
58 |
59 | def __str__(self):
60 | return self.title
61 |
62 | def get_absolute_url(self):
63 | return reverse('articles:article', kwargs={ "article_uuid": self.uuid })
64 |
65 | def generate_pub_invoice(self):
66 |
67 | add_invoice_resp = lnrpc.add_invoice(value=settings.MIN_PUBLISH_AMOUNT, memo="Payment to Paywalled to publish article", expiry=settings.PUBLISH_INVOICE_EXPIRY)
68 | r_hash_base64 = codecs.encode(add_invoice_resp.r_hash, 'base64')
69 | r_hash = r_hash_base64.decode('utf-8')
70 | payment_request = add_invoice_resp.payment_request
71 |
72 | from apps.payments.models import Payment
73 | payment = Payment.objects.create(user=self.user, article=self, purpose=Payment.PUBLISH, satoshi_amount=settings.MIN_PUBLISH_AMOUNT, r_hash=r_hash, payment_request=payment_request, status='pending_payment')
74 | payment.save()
75 |
76 | def generate_view_invoice(self):
77 |
78 | add_invoice_resp = lnrpc.add_invoice(value=settings.MIN_VIEW_AMOUNT, memo=f"Payment to Paywalled to view article: {self.title}.", expiry=settings.VIEW_INVOICE_EXPIRY)
79 | r_hash_base64 = codecs.encode(add_invoice_resp.r_hash, 'base64')
80 | r_hash = r_hash_base64.decode('utf-8')
81 | payment_request = add_invoice_resp.payment_request
82 |
83 | from apps.payments.models import Payment
84 | payment = Payment.objects.create(article=self, purpose=Payment.VIEW, satoshi_amount=settings.MIN_VIEW_AMOUNT, r_hash=r_hash, payment_request=payment_request, status='pending_payment')
85 | payment.save()
86 |
87 | def get_view_count(self):
88 | return self.payments.filter(status="complete", purpose="view").count()
89 |
90 | def get_total_reward(self):
91 | return self.payments.filter(status="complete").aggregate(total_reward=Sum('satoshi_amount'))
--------------------------------------------------------------------------------
/paywalled/templates/blog/draft_list.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load static i18n %}
3 | {% load humanize %}
4 |
5 | {% block title %} {% trans 'Your Drafts' %} {% endblock %}
6 |
7 | {% block head %}
8 |
9 | {% endblock head %}
10 |
11 | {% block content %}
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | {% for article in drafts %}
21 |
22 |
23 |
24 |
25 |
31 |
32 |
35 |
{% trans 'Posted' %}
36 | {{ article.date_published|naturaltime }}
37 | by
38 | {{ article.user.profile.get_screen_name|title }}
39 |
40 |
46 |
47 |
48 |
49 | {% empty %}
50 |
You have no drafts yet.
51 | {% endfor %}
52 |
53 |
54 | {% if is_paginated %}
55 |
77 | {% endif %}
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | {% endblock content %}
--------------------------------------------------------------------------------
/paywalled/templates/blog/article_detail.html:
--------------------------------------------------------------------------------
1 | {% extends 'base.html' %}
2 | {% load static i18n %}
3 | {% load crispy_forms_tags humanize %}
4 |
5 | {% block title %}{{ article.title|title }}{% endblock %}
6 |
7 | {% block content %}
8 |
9 |
26 |
27 |
28 |
29 |
30 |
{{ article.title }}
31 |
Published {{ article.date_published|naturaltime }} by
32 | {{ article.user.profile.get_screen_name }}{% if article.user == request.user %} Edit this
35 | article {% endif %}
36 |
37 |
38 |
39 | {{ article.content|safe }}
40 | {% if not payment_made %}
41 |
42 |
Pay the invoice below to keep reading
43 |
44 | {% endif %}
45 |
46 |
47 | {% if payment_made %}
48 |
Payment confirmed. Thank
49 | you
50 | {% else %}
51 |
52 | {% if invoice %}
53 | {% include 'partials/partial_invoice.html' with purpose="view" %}
54 | {% else %}
55 |
missing invoice
56 | {% endif %}
57 |
58 | {% endif %}
59 |
60 |
61 |
62 |
63 |
64 |
65 | {% if received_payments %}
66 |
67 | {% for payment in received_payments %}
68 | {% include 'partials/article_payment.html' %}
69 | {% endfor %}
70 |
71 | {% else %}
72 |
No payments for this article at this time.
73 | {% endif %}
74 |
75 |
76 |
77 |
78 |
79 | {% endblock content %}
80 |
81 | {% block javascript %}
82 |
117 | {% endblock javascript %}
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Paywalled
2 | Paywalled is an open blogging platform that enables writers to monetize their content with micropayments enabled by the lightning network. Spend a few sats to publish, view, edit and comment on content. Have fun with lightning. Inspired by Alex Bosworth's [Y'alls](https://yalls.org)
3 |
4 | 
5 |
6 | ## A few things
7 | This application uses ordinary user accounts for anyone who wants to publish their content. User accounts allow the platform keep track of all due earnings and make it easy to claim them when required. It also makes edits to content much easier for the user.
8 |
9 | You should have [Lnd](https://github.com/lightningnetwork/lnd/) and [bitcoind](https://github.com/bitcoin/bitcoin) setup already. Plus, for testing, I used regtest. It was quicker to setup and focus on building that way but you can use whatever chain you want. More on this in the Configuration section.
10 |
11 | I also wrote a blog about this, you can read [here](https://rukundo.mataroa.blog/blog/i-built-a-blogging-platform-powered-by-the-lightning-network/)
12 |
13 | ## Setup
14 | Create a virtual environment, clone this repo and install dependencies
15 | ```
16 | % virtualenv venv
17 | % source venv/bin/activate
18 | % git clone https://github.com/crukundo/lnd-paywall.git
19 | % cd lnd-paywall
20 | % pip install -r requirements.txt
21 | ```
22 |
23 | ## Database
24 | This project uses sqlite3. Feel free to use whatever engine you want and reference it in your `.env` and settings.py
25 |
26 | ## Configuration
27 |
28 | Once all the dependencies have been installed, you can then create a `.env` file in the root of the project that contains all the configuration parameters for your instance. I added a sample file you can update. Remember to rename as `.env` and add to `.gitignore`
29 |
30 | The following are a list of currently available configuration options and a
31 | short explanation of what each does.
32 |
33 | `LND_FOLDER` (required)
34 | This is the path to your lnd folder. There should be read access to this path
35 |
36 | `LND_MACAROON_FILE` (required)
37 | This is the path to your admin.macaroon file. This will vary depending on the network chain you decide on. There should be read access to this path
38 |
39 | `LND_TLS_CERT_FILE` (required)
40 | This is the path to your tls.cert file. This is usually located inside your $LND_FOLDER. There should be read access to this path
41 |
42 | Other configs you can change if you want (but remember to update the values from settings when you call your rpc client):
43 |
44 | `LND_NETWORK` (optional; defaults to *regtest*)
45 | This selects the network that your node is configured for. regtest is the default and will have you on your way in no time
46 |
47 | `LND_GRPC_HOST` (optional; defaults to *localhost*)
48 | If your node is not on your local machine (say on a different server), you'll
49 | need to change this value to the appropriate value.
50 |
51 | `LND_GRPC_PORT` (optional; defaults to *10009*)
52 | If the GRPC port for your node was changed to anything other than the default
53 | you'll need to update this as well.
54 |
55 | `MIN_VIEW_AMOUNT` = 1500 (number of satoshis to pay to view an article)
56 |
57 | `MIN_PUBLISH_AMOUNT` = 2100 (number of satoshis to pay to publish an article)
58 |
59 | `PUBLISH_INVOICE_EXPIRY` = 604800 (time until a created lightning invoice to publish an article expires)
60 |
61 | `VIEW_INVOICE_EXPIRY` = 10800 (time until a created lightning invoice to view an article expires)
62 |
63 |
64 | ## Initializing the database
65 |
66 | To initialize the database which would create the database file and all the
67 | necessary tables, run the command:
68 |
69 | ```
70 | % ./manage.py migrate
71 | ```
72 |
73 | ## Running the application server
74 |
75 | Start the application backend by running the command:
76 |
77 | ```
78 | % ./manage.py runserver
79 | ```
80 |
81 | ## Pending matters
82 |
83 | - Add comment section
84 | - Make writers pay to edit their posts 😈
85 | - Add a section for writers with content to claim their rewards and facilitating channel opening to their lnd node through their shared public key
86 | - Possibly add [LNURL-auth](https://github.com/fiatjaf/lnurl-rfc/blob/legacy/lnurl-auth.md) and replace the ordinary user accounts authentication system
87 |
88 | ## Special Credit
89 | - Will Clark's [lnd-grpc](https://github.com/willcl-ark/lnd_grpc) - a python3 gRPC client for LND that did some heavy lifting.
90 | - The incredible supportive team at [Qala](https://qala.dev)
--------------------------------------------------------------------------------
/apps/blog/views.py:
--------------------------------------------------------------------------------
1 | from django.contrib import messages
2 | from django.contrib.auth.mixins import LoginRequiredMixin
3 | from django.contrib.auth.decorators import login_required
4 | from django.http import HttpResponse
5 | from django.views.decorators.http import require_http_methods
6 | from django.views.generic import CreateView, ListView, UpdateView, DetailView, View
7 | from django.urls import reverse
8 | from django.shortcuts import get_object_or_404, redirect, render
9 | from django.utils.translation import ugettext_lazy as _
10 |
11 | from apps.authentication.helpers import AuthorRequiredMixin
12 | from apps.blog.models import Article
13 | from apps.blog.forms import ArticleForm
14 |
15 | @login_required()
16 | def list_drafts(request):
17 | drafts = request.user.articles.filter(status="D")
18 | context = {
19 | "drafts": drafts
20 | }
21 | return render(request, "blog/draft_list.html", context)
22 |
23 | @login_required()
24 | def list_articles(request):
25 | articles = request.user.articles.filter(status="P")
26 | context = {
27 | "articles": articles
28 | }
29 | return render(request, "blog/article_list.html", context)
30 |
31 | @login_required()
32 | def create_new_article(request):
33 | article = Article.objects.create(user=request.user)
34 | article.save()
35 |
36 | try:
37 | article.generate_pub_invoice()
38 | except:
39 | raise NotImplementedError()
40 |
41 | return redirect(reverse("articles:publish_article", kwargs={
42 | 'article_uuid': article.uuid
43 | }))
44 |
45 | @login_required()
46 | def publish_new_article(request, article_uuid):
47 |
48 | article = request.user.articles.get(uuid=article_uuid)
49 | invoice = None
50 | payment_made = False
51 |
52 | try:
53 | # check existence of 'to publish' payments for this article and this user
54 | invoices = article.payments.filter(purpose='publish', user=request.user)
55 | if invoices:
56 | # we just need THE one
57 | invoice = invoices.first()
58 | if invoice.status == 'complete':
59 | payment_made = True
60 | else:
61 | # if no existing payment objects to view this article by this user
62 | article.generate_pub_invoice()
63 | except:
64 | raise NotImplementedError()
65 |
66 | if request.method == "POST":
67 | form = ArticleForm(request.POST, instance=article)
68 | # Publish article after payment
69 | if form.is_valid():
70 | article = form.save(commit=False)
71 | if "publish" in request.POST:
72 | article.status = Article.PUBLISHED
73 | messages.success(request, "Your article: '{}' has been published successfully".format(article.title))
74 |
75 | elif "save" in request.POST:
76 | article.status = Article.DRAFT
77 | messages.success(request, "Draft: '{}' has been saved successfully".format(article.title))
78 | article.save()
79 |
80 | return redirect(reverse("home"))
81 |
82 | else:
83 | form = ArticleForm(instance=article)
84 |
85 | return render(request, "blog/publish_article.html", {
86 | 'article': article,
87 | 'form': form,
88 | 'invoice': invoice,
89 | 'payment_made': payment_made
90 | })
91 |
92 | @login_required()
93 | def delete_draft_article(request, pk):
94 | article = get_object_or_404(Article, pk=pk)
95 | article.delete()
96 | return redirect(reverse("articles:drafts"))
97 |
98 | @login_required()
99 | def delete_article(request, pk):
100 | article = get_object_or_404(Article, pk=pk)
101 | article.delete()
102 | return redirect(reverse("home"))
103 |
104 |
105 | class EditArticleView(LoginRequiredMixin, AuthorRequiredMixin, UpdateView):
106 | """Basic EditView implementation to edit existing articles."""
107 |
108 | model = Article
109 | message = _("Your article has been updated.")
110 | form_class = ArticleForm
111 | template_name = "blog/update_article.html"
112 |
113 | def form_valid(self, form):
114 | form.instance.user = self.request.user
115 | return super().form_valid(form)
116 |
117 | def get_success_url(self):
118 | messages.success(self.request, self.message)
119 | return reverse("home")
120 |
121 | def article_detail(request, article_uuid):
122 | article = Article.objects.get(uuid=article_uuid)
123 | # assume the worst first, lol
124 | payment_made = False
125 | received_payments = None
126 | invoice = None
127 |
128 | try:
129 | # create session key if non-existent
130 | if not request.session.session_key:
131 | request.session.create()
132 | # check if logged in user has a "to view" invoice and whether paid?
133 | if request.user.is_authenticated:
134 | invoices = article.payments.filter(purpose='view', user=request.user)
135 | if invoices:
136 | # we just need THE one
137 | invoice = invoices.last()
138 | if invoice.status == 'complete':
139 | payment_made = True
140 | # add session key to invoice
141 | invoice.session_key = request.session.session_key
142 | else:
143 | article.generate_view_invoice()
144 | invoice = article.payments.filter(purpose='view').latest("created_at")
145 | else:
146 | # get the most recent "to view" invoice for this particular session
147 | invoices = article.payments.filter(purpose='view', session_key=request.session.session_key)
148 | if invoices:
149 | # we just need THE one
150 | invoice = invoices.last()
151 | if invoice.status == 'complete':
152 | payment_made = True
153 | # add session key to invoice
154 | invoice.session_key = request.session.session_key
155 | else:
156 | article.generate_view_invoice()
157 | invoice = article.payments.latest("modified_at")
158 | except:
159 | raise NotImplementedError()
160 |
161 | received_payments = article.payments.filter(status='complete').order_by('-modified_at')
162 |
163 | return render(request, "blog/article_detail.html", {
164 | 'article': article,
165 | 'invoice': invoice,
166 | 'payment_made': payment_made,
167 | 'received_payments': received_payments
168 | })
169 |
170 | # @todo: on edit, check if lightning publish invoice has expired and create a new one. Also mark payment as expired
171 |
--------------------------------------------------------------------------------
/paywalled/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for paywalled project.
3 |
4 | Generated by 'django-admin startproject' using Django 3.2.7.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/3.2/ref/settings/
11 | """
12 | import os
13 |
14 | from pathlib import Path
15 | from decouple import config, Csv
16 | from django import conf
17 |
18 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
19 | BASE_DIR = Path(__file__).resolve().parent
20 |
21 | LND_DIR = os.path.join(os.path.join(os.environ['HOME']), 'app-container', '.lnd')
22 |
23 |
24 | # Quick-start development settings - unsuitable for production
25 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
26 |
27 | SECRET_KEY = config("SECRET_KEY", default="")
28 |
29 |
30 | # SECURITY WARNING: don't run with debug turned on in production!
31 | DEBUG = config("DEBUG", default=True, cast=bool)
32 |
33 | ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="127.0.0.1,localhost", cast=Csv())
34 |
35 |
36 | # Application definition
37 |
38 | INSTALLED_APPS = [
39 | 'django.contrib.admin',
40 | 'django.contrib.auth',
41 | 'django.contrib.contenttypes',
42 | 'django.contrib.sessions',
43 | 'django.contrib.messages',
44 | 'django.contrib.staticfiles',
45 | 'django.contrib.humanize',
46 |
47 | 'compressor',
48 | 'crispy_forms',
49 | 'django_htmx',
50 | 'tinymce',
51 | 'debug_toolbar',
52 | 'django_extensions',
53 | 'apps.authentication',
54 | 'apps.accounts',
55 | 'apps.core',
56 | 'apps.blog',
57 | 'apps.payments',
58 | ]
59 |
60 | MIDDLEWARE = [
61 | 'django.middleware.security.SecurityMiddleware',
62 | 'django.contrib.sessions.middleware.SessionMiddleware',
63 | 'django.middleware.common.CommonMiddleware',
64 | 'django.middleware.csrf.CsrfViewMiddleware',
65 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
66 | 'django.contrib.messages.middleware.MessageMiddleware',
67 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
68 | 'debug_toolbar.middleware.DebugToolbarMiddleware',
69 | 'django_htmx.middleware.HtmxMiddleware'
70 | ]
71 |
72 | ROOT_URLCONF = 'paywalled.urls'
73 |
74 | TEMPLATES = [
75 | {
76 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
77 | "DIRS": [str(BASE_DIR / "templates")],
78 | 'APP_DIRS': True,
79 | 'OPTIONS': {
80 | 'context_processors': [
81 | 'django.template.context_processors.debug',
82 | 'django.template.context_processors.request',
83 | 'django.contrib.auth.context_processors.auth',
84 | 'django.contrib.messages.context_processors.messages',
85 | ],
86 | },
87 | },
88 | ]
89 |
90 | WSGI_APPLICATION = 'paywalled.wsgi.application'
91 |
92 |
93 | # Database
94 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases
95 |
96 | DATABASES = {
97 | 'default': {
98 | 'ENGINE': 'django.db.backends.sqlite3',
99 | 'NAME': BASE_DIR / 'db.sqlite3',
100 | }
101 | }
102 |
103 |
104 | # Password validation
105 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
106 |
107 | AUTH_PASSWORD_VALIDATORS = [
108 | {
109 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
110 | },
111 | {
112 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
113 | },
114 | {
115 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
116 | },
117 | {
118 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
119 | },
120 | ]
121 |
122 |
123 | ABSOLUTE_URL_OVERRIDES = {
124 | "auth.user": lambda u: "/%s/" % u.username,
125 | }
126 |
127 | LOGIN_REDIRECT_URL = "home"
128 | LOGOUT_REDIRECT_URL = "home"
129 | LOGIN_URL = "login"
130 | LOGOUT_URL = "logout"
131 |
132 | # Internationalization
133 | # https://docs.djangoproject.com/en/3.2/topics/i18n/
134 |
135 | LANGUAGE_CODE = 'en-us'
136 |
137 | TIME_ZONE = config("TIME_ZONE", default="UTC")
138 |
139 | USE_I18N = True
140 |
141 | USE_L10N = True
142 |
143 | USE_TZ = True
144 |
145 |
146 | # ==============================================================================
147 | # STATIC FILES SETTINGS
148 | # ==============================================================================
149 |
150 | STATIC_URL = "/static/"
151 | STATIC_ROOT = BASE_DIR.parent / "static"
152 | STATICFILES_DIRS = [str(BASE_DIR / "static")]
153 | STATICFILES_FINDERS = (
154 | "django.contrib.staticfiles.finders.FileSystemFinder",
155 | "django.contrib.staticfiles.finders.AppDirectoriesFinder",
156 | "compressor.finders.CompressorFinder",
157 | )
158 |
159 |
160 | # ==============================================================================
161 | # MEDIA FILES SETTINGS
162 | # ==============================================================================
163 |
164 | MEDIA_URL = "/media/"
165 | MEDIA_ROOT = BASE_DIR.parent / "media/"
166 | DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage"
167 |
168 | # Default primary key field type
169 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
170 |
171 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
172 |
173 | CRISPY_TEMPLATE_PACK = "bootstrap4"
174 |
175 | DEBUG_TOOLBAR_CONFIG = {
176 | "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"],
177 | "SHOW_TEMPLATE_CONTEXT": True,
178 | "SHOW_TOOLBAR_CALLBACK" : lambda request: True,
179 | }
180 |
181 | # ==============================================================================
182 | # TINYMCE
183 | # ==============================================================================
184 |
185 | TINYMCE_DEFAULT_CONFIG = {
186 | "theme": "silver",
187 | "height": 400,
188 | "menubar": False,
189 | "plugins": "advlist,autolink,lists,link,image,charmap,print,preview,anchor,"
190 | "searchreplace,visualblocks,code,fullscreen,insertdatetime,media,table,paste,"
191 | "code,help,wordcount",
192 | "toolbar": "undo redo | formatselect | "
193 | "bold italic backcolor | alignleft aligncenter "
194 | "alignright alignjustify | bullist numlist outdent indent | "
195 | "removeformat | help",
196 | }
197 |
198 |
199 | # PAYWALL SETTINGS
200 | MIN_VIEW_AMOUNT = config("MIN_VIEW_AMOUNT", default="1500", cast=int)
201 | MIN_PUBLISH_AMOUNT = config("MIN_PUBLISH_AMOUNT", default="2100", cast=int)
202 | PUBLISH_INVOICE_EXPIRY = config("PUBLISH_INVOICE_EXPIRY", default="604800", cast=int)
203 | VIEW_INVOICE_EXPIRY = config("VIEW_INVOICE_EXPIRY", default="10800", cast=int)
204 |
205 | LND_FOLDER = LND_DIR
206 | LND_MACAROON_FILE = config("LND_MACAROON_FILE")
207 | LND_TLS_CERT_FILE = config("LND_TLS_CERT_FILE")
208 | LND_NETWORK = config("LND_NETWORK", default="regtest")
--------------------------------------------------------------------------------
/paywalled/static/images/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------