Renew with 2FA and Podman

This commit is contained in:
2025-02-07 21:53:52 +00:00
parent 906f34f3a2
commit 83a3761d17
59 changed files with 1001 additions and 861 deletions

View File

@@ -8,7 +8,10 @@ stop:
docker-compose --env-file=stack.env down docker-compose --env-file=stack.env down
log: log:
docker-compose --env-file=stack.env logs -f docker-compose --env-file=stack.env logs -f --names
test:
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py test $(MODULES) -v 2"
migrate: migrate:
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py migrate" docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py migrate"

View File

@@ -13,7 +13,7 @@ ALLOWED_HOSTS = getenv("ALLOWED_HOSTS", f"127.0.0.1,{DOMAIN}").split(",")
CSRF_TRUSTED_ORIGINS = getenv("CSRF_TRUSTED_ORIGINS", URL).split(",") CSRF_TRUSTED_ORIGINS = getenv("CSRF_TRUSTED_ORIGINS", URL).split(",")
# Stripe # Stripe
STRIPE_ENABLED = getenv("STRIPE_ENABLED", "false").lower() in trues BILLING_ENABLED = getenv("BILLING_ENABLED", "false").lower() in trues
STRIPE_TEST = getenv("STRIPE_TEST", "true") in trues STRIPE_TEST = getenv("STRIPE_TEST", "true") in trues
STRIPE_API_KEY_TEST = getenv("STRIPE_API_KEY_TEST", "") STRIPE_API_KEY_TEST = getenv("STRIPE_API_KEY_TEST", "")
STRIPE_PUBLIC_API_KEY_TEST = getenv("STRIPE_PUBLIC_API_KEY_TEST", "") STRIPE_PUBLIC_API_KEY_TEST = getenv("STRIPE_PUBLIC_API_KEY_TEST", "")
@@ -27,6 +27,12 @@ SECRET_KEY = getenv("SECRET_KEY", "")
STRIPE_ADMIN_COUPON = getenv("STRIPE_ADMIN_COUPON", "") STRIPE_ADMIN_COUPON = getenv("STRIPE_ADMIN_COUPON", "")
REGISTRATION_OPEN = getenv("REGISTRATION_OPEN", "false").lower() in trues
LAGO_API_KEY = getenv("LAGO_API_KEY", "")
LAGO_ORG_ID = getenv("LAGO_ORG_ID", "")
LAGO_URL = getenv("LAGO_URL", "")
DEBUG = getenv("DEBUG", "false") in trues DEBUG = getenv("DEBUG", "false") in trues
PROFILER = getenv("PROFILER", "false") in trues PROFILER = getenv("PROFILER", "false") in trues
@@ -39,4 +45,4 @@ if DEBUG:
"10.0.2.2", "10.0.2.2",
] ]
SETTINGS_EXPORT = ["STRIPE_ENABLED"] SETTINGS_EXPORT = ["BILLING_ENABLED"]

View File

@@ -30,6 +30,7 @@ ALLOWED_HOSTS = []
INSTALLED_APPS = [ INSTALLED_APPS = [
"core", "core",
"django.contrib.admin", "django.contrib.admin",
# 'core.apps.LibraryAdminConfig', # our custom OTP'ed admin
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
@@ -42,7 +43,39 @@ INSTALLED_APPS = [
"crispy_bulma", "crispy_bulma",
# "django_tables2", # "django_tables2",
# "django_tables2_bulma_template", # "django_tables2_bulma_template",
"django_otp",
"django_otp.plugins.otp_totp",
# "django_otp.plugins.otp_email",
# 'django_otp.plugins.otp_hotp',
"django_otp.plugins.otp_static",
"two_factor",
# "two_factor.plugins.phonenumber",
# "two_factor.plugins.email",
# "two_factor.plugins.yubikey",
# "otp_yubikey",
"mixins",
"cachalot",
] ]
# Performance optimisations
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
# "LOCATION": "unix:///var/run/socks/redis.sock",
# "LOCATION": f"redis://{REDIS_HOST}:{REDIS_PORT}",
"LOCATION": "unix:///var/run/envelope-redis.sock",
"OPTIONS": {
"db": 10,
# "parser_class": "django_redis.cache.RedisCache",
# "PASSWORD": REDIS_PASSWORD,
"pool_class": "redis.BlockingConnectionPool",
},
}
}
# CACHE_MIDDLEWARE_ALIAS = 'default'
# CACHE_MIDDLEWARE_SECONDS = '600'
# CACHE_MIDDLEWARE_KEY_PREFIX = ''
CRISPY_TEMPLATE_PACK = "bulma" CRISPY_TEMPLATE_PACK = "bulma"
CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",) CRISPY_ALLOWED_TEMPLATE_PACKS = ("bulma",)
DJANGO_TABLES2_TEMPLATE = "django-tables2/bulma.html" DJANGO_TABLES2_TEMPLATE = "django-tables2/bulma.html"
@@ -51,9 +84,12 @@ MIDDLEWARE = [
"debug_toolbar.middleware.DebugToolbarMiddleware", "debug_toolbar.middleware.DebugToolbarMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
# 'django.middleware.cache.UpdateCacheMiddleware',
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
# 'django.middleware.cache.FetchFromCacheMiddleware',
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django_otp.middleware.OTPMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware", "django_htmx.middleware.HtmxMiddleware",
@@ -132,7 +168,9 @@ AUTH_USER_MODEL = "core.User"
LOGIN_REDIRECT_URL = "home" LOGIN_REDIRECT_URL = "home"
LOGOUT_REDIRECT_URL = "home" LOGOUT_REDIRECT_URL = "home"
LOGIN_URL = "/accounts/login/" # 2FA
LOGIN_URL = "two_factor:login"
# LOGIN_REDIRECT_URL = 'two_factor:profile'
# ALLOWED_PAYMENT_METHODS = ["bacs_debit", "card"] # ALLOWED_PAYMENT_METHODS = ["bacs_debit", "card"]
ALLOWED_PAYMENT_METHODS = ["card"] ALLOWED_PAYMENT_METHODS = ["card"]
@@ -164,6 +202,7 @@ DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.logging.LoggingPanel", "debug_toolbar.panels.logging.LoggingPanel",
"debug_toolbar.panels.redirects.RedirectsPanel", "debug_toolbar.panels.redirects.RedirectsPanel",
"debug_toolbar.panels.profiling.ProfilingPanel", "debug_toolbar.panels.profiling.ProfilingPanel",
"cachalot.panels.CachalotPanel",
] ]
from app.local_settings import * # noqa from app.local_settings import * # noqa
@@ -179,3 +218,12 @@ if PROFILER: # noqa - trust me its there
# "region": f'{os.getenv("REGION")}', # "region": f'{os.getenv("REGION")}',
# } # }
) )
def show_toolbar(request):
return DEBUG # noqa: from local imports
DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": show_toolbar,
}

View File

@@ -16,32 +16,25 @@ Including another URLconf
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.views import LogoutView
from django.urls import include, path from django.urls import include, path
from django.views.generic import TemplateView from django.views.generic import TemplateView
from two_factor.urls import urlpatterns as tf_urls
from core.views import base, demo from core.views import base, notifications
from core.views.callbacks import Callback
urlpatterns = [ urlpatterns = [
path("__debug__/", include("debug_toolbar.urls")), path("__debug__/", include("debug_toolbar.urls")),
path("", base.Home.as_view(), name="home"), path("", base.Home.as_view(), name="home"),
path("callback", Callback.as_view(), name="callback"),
path("billing/", base.Billing.as_view(), name="billing"),
path("order/<str:plan_name>/", base.Order.as_view(), name="order"),
path(
"cancel_subscription/<str:plan_name>/",
base.Cancel.as_view(),
name="cancel_subscription",
),
path(
"success/", TemplateView.as_view(template_name="success.html"), name="success"
),
path("cancel/", TemplateView.as_view(template_name="cancel.html"), name="cancel"),
path("portal", base.Portal.as_view(), name="portal"),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls")), # 2FA login urls
path("", include(tf_urls)),
path("accounts/signup/", base.Signup.as_view(), name="signup"), path("accounts/signup/", base.Signup.as_view(), name="signup"),
path("demo/modal/", demo.DemoModal.as_view(), name="modal"), path("accounts/logout/", LogoutView.as_view(), name="logout"),
path("demo/widget/", demo.DemoWidget.as_view(), name="widget"), # Notifications
path("demo/window/", demo.DemoWindow.as_view(), name="window"), path(
"notifications/<str:type>/update/",
notifications.NotificationsUpdate.as_view(),
name="notifications_update",
),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@@ -1,6 +1,6 @@
import os import os
import stripe # import stripe
from django.conf import settings from django.conf import settings
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
@@ -8,7 +8,7 @@ os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
# r = StrictRedis(unix_socket_path="/var/run/redis/redis.sock", db=0) # r = StrictRedis(unix_socket_path="/var/run/redis/redis.sock", db=0)
if settings.STRIPE_TEST: # if settings.STRIPE_TEST:
stripe.api_key = settings.STRIPE_API_KEY_TEST # stripe.api_key = settings.STRIPE_API_KEY_TEST
else: # else:
stripe.api_key = settings.STRIPE_API_KEY_PROD # stripe.api_key = settings.STRIPE_API_KEY_PROD

View File

@@ -2,32 +2,35 @@ from django.contrib import admin
from django.contrib.auth.admin import UserAdmin from django.contrib.auth.admin import UserAdmin
from .forms import CustomUserCreationForm from .forms import CustomUserCreationForm
from .models import Plan, Session, User from .models import NotificationSettings, User
# Register your models here. # Register your models here.
class CustomUserAdmin(UserAdmin): class CustomUserAdmin(UserAdmin):
list_filter = ["plans"] # list_filter = ["plans"]
model = User model = User
add_form = CustomUserCreationForm add_form = CustomUserCreationForm
fieldsets = ( fieldsets = (
*UserAdmin.fieldsets, *UserAdmin.fieldsets,
( (
"Stripe information", "Billing information",
{"fields": ("stripe_id",)}, {"fields": ("billing_provider_id", "customer_id", "stripe_id")},
),
(
"Payment information",
{
"fields": (
"plans",
"last_payment",
)
},
), ),
# (
# "Payment information",
# {
# "fields": (
# # "plans",
# "last_payment",
# )
# },
# ),
) )
class NotificationSettingsAdmin(admin.ModelAdmin):
list_display = ("user", "ntfy_topic", "ntfy_url")
admin.site.register(User, CustomUserAdmin) admin.site.register(User, CustomUserAdmin)
admin.site.register(Plan) admin.site.register(NotificationSettings, NotificationSettingsAdmin)
admin.site.register(Session)

View File

@@ -1,7 +1,9 @@
from django import forms from django import forms
from django.contrib.auth.forms import UserCreationForm from django.contrib.auth.forms import UserCreationForm
from django.forms import ModelForm
from mixins.restrictions import RestrictedFormMixin
from .models import User from .models import NotificationSettings, User
# Create your forms here. # Create your forms here.
@@ -28,6 +30,19 @@ class NewUserForm(UserCreationForm):
return user return user
class NotificationSettingsForm(RestrictedFormMixin, ModelForm):
class Meta:
model = NotificationSettings
fields = (
"ntfy_topic",
"ntfy_url",
)
help_texts = {
"ntfy_topic": "The topic to send notifications to.",
"ntfy_url": "Custom NTFY server. Leave blank to use the default server.",
}
class CustomUserCreationForm(UserCreationForm): class CustomUserCreationForm(UserCreationForm):
class Meta: class Meta:
model = User model = User

View File

@@ -1,65 +0,0 @@
import logging
import stripe
logger = logging.getLogger(__name__)
def expand_name(first_name, last_name):
"""
Convert two name variables into one.
Last name without a first name is ignored.
"""
name = None
if first_name:
name = first_name
# We only want to put the last name if we have a first name
if last_name:
name += f" {last_name}"
return name
def get_or_create(email, first_name, last_name):
"""
Get a customer ID from Stripe if one with the given email exists.
Create a customer if one does not.
Raise an exception if two or more customers matching the given email exist.
"""
# Let's see if we're just missing the ID
matching_customers = stripe.Customer.list(email=email, limit=2)
if len(matching_customers) == 2:
# Something is horribly wrong
logger.error(f"Two customers found for email {email}")
raise Exception(f"Two customers found for email {email}")
elif len(matching_customers) == 1:
# We found a customer. Let's copy the ID
customer = matching_customers["data"][0]
customer_id = customer["id"]
return customer_id
else:
# We didn't find anything. Create the customer
# Create a name, since we have 2 variables which could be null
name = expand_name(first_name, last_name)
cast = {"email": email}
if name:
cast["name"] = name
customer = stripe.Customer.create(**cast)
logger.info(f"Created new Stripe customer {customer.id} with email {email}")
return customer.id
def update_customer_fields(stripe_id, email=None, first_name=None, last_name=None):
"""
Update the customer fields in Stripe.
"""
if email:
stripe.Customer.modify(stripe_id, email=email)
logger.info(f"Modified Stripe customer {stripe_id} to have email {email}")
if first_name or last_name:
name = expand_name(first_name, last_name)
stripe.Customer.modify(stripe_id, name=name)
logger.info(f"Modified Stripe customer {stripe_id} to have email {name}")

38
core/lib/notify.py Normal file
View File

@@ -0,0 +1,38 @@
import requests
from core.util import logs
NTFY_URL = "https://ntfy.sh"
log = logs.get_logger(__name__)
# Actual function to send a message to a topic
def raw_sendmsg(msg, title=None, priority=None, tags=None, url=None, topic=None):
if url is None:
url = NTFY_URL
headers = {"Title": "GIA"}
if title:
headers["Title"] = title
if priority:
headers["Priority"] = priority
if tags:
headers["Tags"] = tags
requests.post(
f"{url}/{topic}",
data=msg,
headers=headers,
)
# Sendmsg helper to send a message to a user's notification settings
def sendmsg(user, *args, **kwargs):
notification_settings = user.get_notification_settings()
if notification_settings.ntfy_topic is None:
# No topic set, so don't send
return
else:
topic = notification_settings.ntfy_topic
raw_sendmsg(*args, **kwargs, url=notification_settings.ntfy_url, topic=topic)

View File

@@ -1,19 +0,0 @@
from core.models import Plan
def assemble_plan_map(product_id_filter=None):
"""
Get all the plans from the database and create an object Stripe wants.
"""
line_items = []
for plan in Plan.objects.all():
if product_id_filter:
if plan.product_id != product_id_filter:
continue
line_items.append(
{
"price": plan.product_id,
"quantity": 1,
}
)
return line_items

View File

View File

View File

@@ -0,0 +1,11 @@
import msgpack
from django.core.management.base import BaseCommand
from django.conf import settings
from core.util import logs
log = logs.get_logger("processing")
class Command(BaseCommand):
def handle(self, *args, **options):
...

View File

@@ -0,0 +1,41 @@
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from asgiref.sync import sync_to_async
from django.core.management.base import BaseCommand
from core.util import logs
log = logs.get_logger("scheduling")
INTERVALS = [5, 60, 900, 1800, 3600, 14400, 86400]
async def job(interval_seconds):
"""
Run all schedules matching the given interval.
:param interval_seconds: The interval to run.
"""
class Command(BaseCommand):
def handle(self, *args, **options):
"""
Start the scheduling process.
"""
scheduler = AsyncIOScheduler()
for interval in INTERVALS:
log.debug(f"Scheduling {interval} second job")
scheduler.add_job(job, "interval", seconds=interval, args=[interval])
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
scheduler._eventloop = loop
scheduler.start()
try:
loop.run_forever()
except (KeyboardInterrupt, SystemExit):
log.info("Process terminating")
finally:
loop.close()

View File

@@ -1,4 +1,6 @@
# Generated by Django 4.0.6 on 2022-07-10 19:54 # Generated by Django 4.2.6 on 2023-10-17 19:26
import uuid
import django.contrib.auth.models import django.contrib.auth.models
import django.contrib.auth.validators import django.contrib.auth.validators
@@ -31,9 +33,11 @@ class Migration(migrations.Migration):
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('stripe_id', models.CharField(blank=True, max_length=255, null=True)), ('stripe_id', models.CharField(blank=True, max_length=255, null=True)),
('last_payment', models.DateTimeField(blank=True, null=True)), ('customer_id', models.UUIDField(blank=True, default=uuid.uuid4, null=True)),
('billing_provider_id', models.CharField(blank=True, max_length=255, null=True)),
('email', models.EmailField(max_length=254, unique=True)), ('email', models.EmailField(max_length=254, unique=True)),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
], ],
options={ options={
'verbose_name': 'user', 'verbose_name': 'user',
@@ -45,34 +49,12 @@ class Migration(migrations.Migration):
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Plan', name='NotificationSettings',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)), ('ntfy_topic', models.CharField(blank=True, max_length=255, null=True)),
('description', models.CharField(blank=True, max_length=1024, null=True)), ('ntfy_url', models.CharField(blank=True, max_length=255, null=True)),
('cost', models.IntegerField()), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('product_id', models.CharField(blank=True, max_length=255, null=True, unique=True)),
('image', models.CharField(blank=True, max_length=1024, null=True)),
], ],
), ),
migrations.CreateModel(
name='Session',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('request', models.CharField(blank=True, max_length=255, null=True)),
('subscription_id', models.CharField(blank=True, max_length=255, null=True)),
('plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='core.plan')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='user',
name='plans',
field=models.ManyToManyField(blank=True, to='core.plan'),
),
migrations.AddField(
model_name='user',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions'),
),
] ]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.0.6 on 2022-10-12 09:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='session',
name='session',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@@ -1,77 +1,35 @@
import logging import logging
import uuid
import stripe
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from core.lib.customers import get_or_create, update_customer_fields
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Plan(models.Model):
name = models.CharField(max_length=255, unique=True)
description = models.CharField(max_length=1024, null=True, blank=True)
cost = models.IntegerField()
product_id = models.CharField(max_length=255, unique=True, null=True, blank=True)
image = models.CharField(max_length=1024, null=True, blank=True)
def __str__(self):
return f"{self.name}{self.cost})"
class User(AbstractUser): class User(AbstractUser):
# Stripe customer ID # Stripe customer ID
stripe_id = models.CharField(max_length=255, null=True, blank=True) stripe_id = models.CharField(max_length=255, null=True, blank=True)
last_payment = models.DateTimeField(null=True, blank=True) customer_id = models.UUIDField(default=uuid.uuid4, null=True, blank=True)
plans = models.ManyToManyField(Plan, blank=True) billing_provider_id = models.CharField(max_length=255, null=True, blank=True)
email = models.EmailField(unique=True) email = models.EmailField(unique=True)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._original = self self._original = self
def save(self, *args, **kwargs): def get_notification_settings(self):
""" return NotificationSettings.objects.get_or_create(user=self)[0]
Override the save function to create a Stripe customer.
"""
if settings.STRIPE_ENABLED:
if not self.stripe_id: # stripe ID not stored
self.stripe_id = get_or_create(
self.email, self.first_name, self.last_name
)
to_update = {}
if self.email != self._original.email:
to_update["email"] = self.email
if self.first_name != self._original.first_name:
to_update["first_name"] = self.first_name
if self.last_name != self._original.last_name:
to_update["last_name"] = self.last_name
update_customer_fields(self.stripe_id, **to_update)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
if settings.STRIPE_ENABLED:
if self.stripe_id:
stripe.Customer.delete(self.stripe_id)
logger.info(f"Deleted Stripe customer {self.stripe_id}")
super().delete(*args, **kwargs)
def has_plan(self, plan):
plan_list = [plan.name for plan in self.plans.all()]
return plan in plan_list
class Session(models.Model): class NotificationSettings(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE)
request = models.CharField(max_length=255, null=True, blank=True) ntfy_topic = models.CharField(max_length=255, null=True, blank=True)
session = models.CharField(max_length=255, null=True, blank=True) ntfy_url = models.CharField(max_length=255, null=True, blank=True)
subscription_id = models.CharField(max_length=255, null=True, blank=True)
plan = models.ForeignKey(Plan, null=True, blank=True, on_delete=models.CASCADE) def __str__(self):
return f"Notification settings for {self.user}"
# class Perms(models.Model): # class Perms(models.Model):

File diff suppressed because one or more lines are too long

View File

@@ -6,16 +6,24 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>XF - {{ request.path_info }}</title> <title>GIA - {{ request.path_info }}</title>
<link rel="shortcut icon" href="{% static 'favicon.ico' %}"> <link rel="shortcut icon" href="{% static 'favicon.ico' %}">
<link rel="manifest" href="{% static 'manifest.webmanifest' %}"> <link rel="manifest" href="{% static 'manifest.webmanifest' %}">
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}"> <link rel="stylesheet" href="{% static 'css/bulma.min.css' %}">
<link rel="stylesheet" href="{% static 'css/bulma-tooltip.min.css' %}"> <link rel="stylesheet" href="{% static 'css/bulma-tooltip.min.css' %}">
<link rel="stylesheet" href="https://site-assets.fontawesome.com/releases/v6.1.1/css/all.css"> <link rel="stylesheet" href="https://site-assets.fontawesome.com/releases/v6.1.1/css/all.css">
<link rel="stylesheet" href="{% static 'css/bulma-slider.min.css' %}">
<link rel="stylesheet" href="{% static 'css/bulma-calendar.min.css' %}">
<link rel="stylesheet" href="{% static 'css/bulma-tagsinput.min.css' %}">
<link rel="stylesheet" href="{% static 'css/bulma-switch.min.css' %}">
<link rel="stylesheet" href="{% static 'css/gridstack.min.css' %}"> <link rel="stylesheet" href="{% static 'css/gridstack.min.css' %}">
<script src="{% static 'js/bulma-calendar.min.js' %}" integrity="sha384-DThNif0xGXbopX7+PE+UabkuClfI/zELNhaVqoGLutaWB76dyMw0vIQBGmUxSfVQ" crossorigin="anonymous"></script>
<script src="{% static 'js/bulma-slider.min.js' %}" integrity="sha384-wbyps8iLG8QzJE02viYc/27BtT5HSa11+b5V7QPR1/huVuA8f4LRTNGc82qAIeIZ" crossorigin="anonymous"></script>
<script src="{% static 'js/htmx.min.js' %}" integrity="sha384-cZuAZ+ZbwkNRnrKi05G/fjBX+azI9DNOkNYysZ0I/X5ZFgsmMiBXgDZof30F5ofc" crossorigin="anonymous"></script> <script src="{% static 'js/htmx.min.js' %}" integrity="sha384-cZuAZ+ZbwkNRnrKi05G/fjBX+azI9DNOkNYysZ0I/X5ZFgsmMiBXgDZof30F5ofc" crossorigin="anonymous"></script>
<script defer src="{% static 'js/hyperscript.min.js' %}" integrity="sha384-6GYN8BDHOJkkru6zcpGOUa//1mn+5iZ/MyT6mq34WFIpuOeLF52kSi721q0SsYF9" crossorigin="anonymous"></script>
<script defer src="{% static 'js/remove-me.js' %}" integrity="sha384-6fHcFNoQ8QEI3ZDgw9Z/A6Brk64gF7AnFbLgdrumo8/kBbsKQ/wo7wPegj5WkzuG" crossorigin="anonymous"></script> <script defer src="{% static 'js/remove-me.js' %}" integrity="sha384-6fHcFNoQ8QEI3ZDgw9Z/A6Brk64gF7AnFbLgdrumo8/kBbsKQ/wo7wPegj5WkzuG" crossorigin="anonymous"></script>
<script defer src="{% static 'js/hyperscript.min.js' %}" integrity="sha384-6GYN8BDHOJkkru6zcpGOUa//1mn+5iZ/MyT6mq34WFIpuOeLF52kSi721q0SsYF9" crossorigin="anonymous"></script>
<script src="{% static 'js/bulma-tagsinput.min.js' %}"></script>
<script src="{% static 'js/jquery.min.js' %}"></script>
<script src="{% static 'js/gridstack-all.js' %}"></script> <script src="{% static 'js/gridstack-all.js' %}"></script>
<script defer src="{% static 'js/magnet.min.js' %}"></script> <script defer src="{% static 'js/magnet.min.js' %}"></script>
<script> <script>
@@ -112,12 +120,37 @@
cursor:pointer; cursor:pointer;
background-color:rgba(221, 224, 255, 0.3) !important; background-color:rgba(221, 224, 255, 0.3) !important;
} }
@media (prefers-color-scheme: dark) {
:root {
--background-color: rgba(84, 84, 84, 0.9) !important;
--modal-color: rgba(81, 81, 81, 0.9) !important;
}
}
:root {
--background-color-light: rgba(210, 210, 210, 0.9) !important;
--background-color-dark: rgba(84, 84, 84, 0.9) !important;
--background-color-modal-light: rgba(250, 250, 250, 0.5) !important;
--background-color-modal-dark: rgba(210, 210, 210, 0.9) !important;
}
[data-theme="light"] {
--background-color: var(--background-color-light);
--modal-color: var(--background-color-modal-light);
}
[data-theme="dark"] {
--background-color: var(--background-color-dark);
--modal-color: var(--background-color-modal-dark);
}
.panel, .box, .modal { .panel, .box, .modal {
background-color:rgba(250, 250, 250, 0.5) !important; /* background-color:rgba(250, 250, 250, 0.5) !important; */
background-color: var(--modal-color) !important;
} }
.modal, .modal.box{ .modal, .modal.box{
background-color:rgba(210, 210, 210, 0.9) !important; /* background-color:rgba(210, 210, 210, 0.9) !important; */
background-color: var(--background-color) !important;
} }
.modal-background{ .modal-background{
background-color:rgba(255, 255, 255, 0.3) !important; background-color:rgba(255, 255, 255, 0.3) !important;
@@ -201,25 +234,34 @@
<a class="navbar-item" href="{% url 'home' %}"> <a class="navbar-item" href="{% url 'home' %}">
Home Home
</a> </a>
{% if settings.STRIPE_ENABLED %}
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a class="navbar-item" href="{% url 'billing' %}">
Billing
</a>
{% endif %}
{% endif %}
{% if user.is_superuser %}
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link"> <a class="navbar-link">
Admin Account
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="{% url 'two_factor:profile' %}">
Security
</a>
<a class="navbar-item" href="{% url 'notifications_update' type='page' %}">
Notifications
</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
Services
</a> </a>
<div class="navbar-dropdown"> <div class="navbar-dropdown">
<a class="navbar-item" href="#"> <a class="navbar-item" href="#">
Admin1 Signal
</a> </a>
<a class="navbar-item" href="#"> <a class="navbar-item" href="#">
Admin2 Instagram
</a> </a>
</div> </div>
</div> </div>
@@ -236,7 +278,7 @@
<a class="button is-info" href="{% url 'signup' %}"> <a class="button is-info" href="{% url 'signup' %}">
<strong>Sign up</strong> <strong>Sign up</strong>
</a> </a>
<a class="button is-light" href="{% url 'login' %}"> <a class="button" href="{% url 'two_factor:login' %}">
Log in Log in
</a> </a>
{% endif %} {% endif %}
@@ -283,8 +325,16 @@
{% endblock %} {% endblock %}
<section class="section"> <section class="section">
<div class="container"> <div class="container">
{% block content %} {% block content_wrapper %}
{% block content %}
{% endblock %}
{% endblock %} {% endblock %}
<div id="modals-here">
</div>
<div id="windows-here">
</div>
<div id="widgets-here" style="display: none;">
</div>
</div> </div>
</section> </section>
</body> </body>

View File

@@ -1,36 +0,0 @@
{% extends "base.html" %}
{% block content %}
<article class="panel is-info">
<p class="panel-heading">
User information
</p>
<a class="panel-block is-active">
<span class="panel-icon">
<i class="fas fa-id-card" aria-hidden="true"></i>
</span>
<span class="tag is-info">{{ user.first_name }} {{ user.last_name }}</span>
</a>
<a class="panel-block">
<span class="panel-icon">
<i class="fas fa-binary" aria-hidden="true"></i>
</span>
{% for plan in user.plans.all %}
<span class="tag is-info">{{ plan.name }}</span>
{% endfor %}
</a>
<a class="panel-block">
<span class="panel-icon">
<i class="fas fa-credit-card" aria-hidden="true"></i>
</span>
<span class="tag">{{ user.last_payment }}</span>
</a>
<a class="panel-block" href="{% url 'portal' %}">
<span class="panel-icon">
<i class="fa-brands fa-stripe-s" aria-hidden="true"></i>
</span>
Subscription management
</a>
</article>
{% include "partials/product-list.html" %}
{% endblock %}

View File

@@ -4,19 +4,6 @@
{% block outer_content %} {% block outer_content %}
<div class="grid-stack" id="grid-stack-main"> <div class="grid-stack" id="grid-stack-main">
<div class="grid-stack-item" gs-w="7" gs-h="10" gs-y="0" gs-x="1">
<div class="grid-stack-item-content">
<nav class="panel">
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
Home
</p>
<article class="panel-block is-active">
{% include 'window-content/main.html' %}
</article>
</nav>
</div>
</div>
</div> </div>
<script> <script>
@@ -34,62 +21,73 @@
// a widget is ready to be loaded // a widget is ready to be loaded
document.addEventListener('load-widget', function(event) { document.addEventListener('load-widget', function(event) {
let container = htmx.find('#widget'); let containers = htmx.findAll('#widget');
// get the scripts, they won't be run on the new element so we need to eval them for (let x = 0, len = containers.length; x < len; x++) {
var scripts = htmx.findAll(container, "script"); container = containers[x];
let widgetelement = container.firstElementChild.cloneNode(true); // get the scripts, they won't be run on the new element so we need to eval them
var new_id = widgetelement.id; let widgetelement = container.firstElementChild.cloneNode(true);
console.log(widgetelement);
var scripts = htmx.findAll(widgetelement, "script");
var new_id = widgetelement.id;
// check if there's an existing element like the one we want to swap // check if there's an existing element like the one we want to swap
let grid_element = htmx.find('#grid-stack-main'); let grid_element = htmx.find('#grid-stack-main');
let existing_widget = htmx.find(grid_element, "#"+new_id); let existing_widget = htmx.find(grid_element, "#"+new_id);
// get the size and position attributes // get the size and position attributes
if (existing_widget) { if (existing_widget) {
let attrs = existing_widget.getAttributeNames(); let attrs = existing_widget.getAttributeNames();
for (let i = 0, len = attrs.length; i < len; i++) { for (let i = 0, len = attrs.length; i < len; i++) {
if (attrs[i].startsWith('gs-')) { // only target gridstack attributes if (attrs[i].startsWith('gs-')) { // only target gridstack attributes
widgetelement.setAttribute(attrs[i], existing_widget.getAttribute(attrs[i])); widgetelement.setAttribute(attrs[i], existing_widget.getAttribute(attrs[i]));
}
} }
} }
} // clear the queue element
// clear the queue element container.outerHTML = "";
container.outerHTML = ""; // container.firstElementChild.outerHTML = "";
grid.addWidget(widgetelement); grid.addWidget(widgetelement);
// re-create the HTMX JS listeners, otherwise HTMX won't work inside the grid // re-create the HTMX JS listeners, otherwise HTMX won't work inside the grid
htmx.process(widgetelement); htmx.process(widgetelement);
// update the size of the widget according to its content // update the size of the widget according to its content
var added_widget = htmx.find(grid_element, "#"+new_id); var added_widget = htmx.find(grid_element, "#"+new_id);
var itemContent = htmx.find(added_widget, ".control"); var itemContent = htmx.find(added_widget, ".control");
var scrollheight = itemContent.scrollHeight+80; var scrollheight = itemContent.scrollHeight+80;
var verticalmargin = 0; var verticalmargin = 0;
var cellheight = grid.opts.cellHeight; var cellheight = grid.opts.cellHeight;
var height = Math.ceil((scrollheight + verticalmargin) / (cellheight + verticalmargin)); var height = Math.ceil((scrollheight + verticalmargin) / (cellheight + verticalmargin));
var opts = { var opts = {
h: height, h: height,
} }
grid.update( grid.update(
added_widget, added_widget,
opts opts
); );
// run the JS scripts inside the added element again // run the JS scripts inside the added element again
for (var i = 0; i < scripts.length; i++) { for (var i = 0; i < scripts.length; i++) {
eval(scripts[i].innerHTML); eval(scripts[i].innerHTML);
}
} }
// clear the containers we just added
// for (let x = 0, len = containers.length; x < len; x++) {
// container = containers[x];
// container.inner = "";
// }
grid.compact();
}); });
</script> </script>
<div>
<!-- <div
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="#"
hx-target="#widgets-here"
hx-trigger="load"
hx-swap="afterend"
style="display: none;"></div> -->
<div id="modals-here"> </div>
</div>
<div id="items-here">
</div>
<div id="widgets-here" style="display: none;">
</div>
<script>
</script>
{% endblock %} {% endblock %}

View File

@@ -1,5 +0,0 @@
{% extends 'wm/modal.html' %}
{% block modal_content %}
{% include 'window-content/main.html' %}
{% endblock %}

View File

@@ -1,48 +0,0 @@
{% load static %}
{% for plan in plans %}
<div class="box">
<article class="media">
<div class="media-left">
<figure class="image is-64x64">
<img src="{% static plan.image %}" alt="Image">
</figure>
</div>
<div class="media-content">
<div class="content">
<p>
<strong>{{ plan.name }}</strong> <small>£{{ plan.cost }}</small>
{% if plan in user_plans %}
<i class="fas fa-check" aria-hidden="true"></i>
{% endif %}
<br>
{{ plan.description }}
</p>
</div>
<nav class="level is-mobile">
<div class="level-left">
{% if plan not in user_plans %}
<a class="level-item" href="/order/{{ plan.name }}">
<span class="icon is-small has-text-success">
<i class="fas fa-plus" aria-hidden="true"></i>
</span>
</a>
{% endif %}
{% if plan in user_plans %}
<a class="level-item" href="/cancel_subscription/{{ plan.name }}">
<span class="icon is-small has-text-info">
<i class="fas fa-cancel" aria-hidden="true"></i>
</span>
</a>
{% endif %}
</div>
</nav>
</div>
</article>
</div>
{% endfor %}

View File

@@ -7,12 +7,12 @@
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">
<div class="columns is-centered"> <div class="columns is-centered">
<div class="column is-5-tablet is-4-desktop is-3-widescreen"> <div class="column is-5-tablet is-5-desktop is-4-widescreen">
<form method="POST" class="box"> <form method="POST" class="box">
{% csrf_token %} {% csrf_token %}
{{ form|crispy }} {{ form|crispy }}
<div class="field"> <div class="field">
<button class="button is-success"> <button class="button">
Login Login
</button> </button>
</div> </div>
@@ -22,4 +22,4 @@
</div> </div>
</div> </div>
</section> </section>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1 @@
{% extends 'base.html' %}

View File

@@ -0,0 +1,16 @@
{% extends "two_factor/_base.html" %}
{% block content_wrapper %}
<section class="hero is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns is-centered">
<div class="column box is-5-tablet is-5-desktop is-4-widescreen">
{% block content %}{% endblock content %}
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% load i18n %}
<div class="buttons">
{% if cancel_url %}
<a href="{{ cancel_url }}"
class="button">{% trans "Cancel" %}</a>
{% endif %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit"
value="{{ wizard.steps.prev }}"
class="button">{% trans "Back" %}</button>
{% else %}
<button disabled name="" type="button" class="button">{% trans "Back" %}</button>
{% endif %}
<button type="submit" class="button">{% trans "Next" %}</button>
</div>

View File

@@ -0,0 +1,6 @@
{% load crispy_forms_tags %}
<table class="is-3">
{{ wizard.management_form|crispy }}
{{ wizard.form|crispy }}
</table>

View File

@@ -0,0 +1,28 @@
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% block title %}{% trans "Backup Tokens" %}{% endblock %}</h1>
<p class="subtitle">{% blocktrans trimmed %}Backup tokens can be used when your primary and backup
phone numbers aren't available. The backup tokens below can be used
for login verification. If you've used up all your backup tokens, you
can generate a new set of backup tokens. Only the backup tokens shown
below will be valid.{% endblocktrans %}</p>
{% if device.token_set.count %}
<ul>
{% for token in device.token_set.all %}
<li>{{ token.token }}</li>
{% endfor %}
</ul>
<p class="subtitle">{% blocktrans %}Print these tokens and keep them somewhere safe.{% endblocktrans %}</p>
{% else %}
<p class="subtitle">{% trans "You don't have any backup codes yet." %}</p>
{% endif %}
<form method="post">{% csrf_token %}{{ form }}
<a href="{% url 'two_factor:profile'%}"
class="float-right button">{% trans "Back to Account Security" %}</a>
<button class="button" type="submit">{% trans "Generate Tokens" %}</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% block title %}{% trans "Login" %}{% endblock %}</h1>
{% if wizard.steps.current == 'auth' %}
<p class="subtitle">{% blocktrans %}Enter your credentials.{% endblocktrans %}</p>
{% elif wizard.steps.current == 'token' %}
{% if device.method == 'call' %}
<p class="subtitle">{% blocktrans trimmed %}We are calling your phone right now, please enter the
digits you hear.{% endblocktrans %}</p>
{% elif device.method == 'sms' %}
<p class="subtitle">{% blocktrans trimmed %}We sent you a text message, please enter the tokens we
sent.{% endblocktrans %}</p>
{% else %}
<p class="subtitle">{% blocktrans trimmed %}Please enter the tokens generated by your token
generator.{% endblocktrans %}</p>
{% endif %}
{% elif wizard.steps.current == 'backup' %}
<p class="subtitle">{% blocktrans trimmed %}Use this form for entering backup tokens for logging in.
These tokens have been generated for you to print and keep safe. Please
enter one of these backup tokens to login to your account.{% endblocktrans %}</p>
{% endif %}
<form action="" method="post">{% csrf_token %}
{% include "two_factor/_wizard_forms.html" %}
{# hidden submit button to enable [enter] key #}
<input type="submit" value="" style="display:none" />
{% if other_devices %}
<p class="subtitle">{% trans "Or, alternatively, use one of your backup phones:" %}</p>
<p class="subtitle">
{% for other in other_devices %}
<button name="challenge_device" value="{{ other.persistent_id }}"
class="button" type="submit">
{{ other.generate_challenge_button_title }}
</button>
{% endfor %}</p>
{% endif %}
{% if backup_tokens %}
<p class="subtitle">{% trans "As a last resort, you can use a backup token:" %}</p>
<p class="subtitle">
<button name="wizard_goto_step" type="submit" value="backup"
class="button">{% trans "Use Backup Token" %}</button>
</p>
{% endif %}
{% include "two_factor/_wizard_actions.html" %}
</form>
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% block title %}{% trans "Permission Denied" %}{% endblock %}</h1>
<p class="subtitle">{% blocktrans trimmed %}The page you requested, enforces users to verify using
two-factor authentication for security reasons. You need to enable these
security features in order to access this page.{% endblocktrans %}</p>
<p class="subtitle">{% blocktrans trimmed %}Two-factor authentication is not enabled for your
account. Enable two-factor authentication for enhanced account
security.{% endblocktrans %}</p>
<div class="buttons">
<a href="javascript:history.go(-1)"
class="float-right button">{% trans "Go back" %}</a>
<a href="{% url 'two_factor:setup' %}" class="button">
{% trans "Enable Two-Factor Authentication" %}</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% block title %}{% trans "Add Backup Phone" %}{% endblock %}</h1>
{% if wizard.steps.current == 'setup' %}
<p class="subtitle">{% blocktrans trimmed %}You'll be adding a backup phone number to your
account. This number will be used if your primary method of
registration is not available.{% endblocktrans %}</p>
{% elif wizard.steps.current == 'validation' %}
<p class="subtitle">{% blocktrans trimmed %}We've sent a token to your phone number. Please
enter the token you've received.{% endblocktrans %}</p>
{% endif %}
<form action="" method="post">{% csrf_token %}
{% include "two_factor/_wizard_forms.html" %}
{# hidden submit button to enable [enter] key #}
<input type="submit" value="" style="display:none" />
{% include "two_factor/_wizard_actions.html" %}
</form>
{% endblock %}

View File

@@ -0,0 +1,56 @@
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}</h1>
{% if wizard.steps.current == 'welcome' %}
<p class="subtitle">{% blocktrans trimmed %}You are about to take your account security to the
next level. Follow the steps in this wizard to enable two-factor
authentication.{% endblocktrans %}</p>
{% elif wizard.steps.current == 'method' %}
<p class="subtitle">{% blocktrans trimmed %}Please select which authentication method you would
like to use.{% endblocktrans %}</p>
{% elif wizard.steps.current == 'generator' %}
<p class="subtitle">{% blocktrans trimmed %}To start using a token generator, please use your
smartphone to scan the QR code below. For example, use Google
Authenticator. Then, enter the token generated by the app.
{% endblocktrans %}</p>
<p class="subtitle"><img src="{{ QR_URL }}" alt="QR Code" class="bg-white"/></p>
{% elif wizard.steps.current == 'sms' %}
<p class="subtitle">{% blocktrans trimmed %}Please enter the phone number you wish to receive the
text messages on. This number will be validated in the next step.
{% endblocktrans %}</p>
{% elif wizard.steps.current == 'call' %}
<p class="subtitle">{% blocktrans trimmed %}Please enter the phone number you wish to be called on.
This number will be validated in the next step. {% endblocktrans %}</p>
{% elif wizard.steps.current == 'validation' %}
{% if challenge_succeeded %}
{% if device.method == 'call' %}
<p class="subtitle">{% blocktrans trimmed %}We are calling your phone right now, please enter the
digits you hear.{% endblocktrans %}</p>
{% elif device.method == 'sms' %}
<p class="subtitle">{% blocktrans trimmed %}We sent you a text message, please enter the tokens we
sent.{% endblocktrans %}</p>
{% endif %}
{% else %}
<p class="alert alert-warning" role="alert">{% blocktrans trimmed %}We've
encountered an issue with the selected authentication method. Please
go back and verify that you entered your information correctly, try
again, or use a different authentication method instead. If the issue
persists, contact the site administrator.{% endblocktrans %}</p>
{% endif %}
{% elif wizard.steps.current == 'yubikey' %}
<p class="subtitle">{% blocktrans trimmed %}To identify and verify your YubiKey, please insert a
token in the field below. Your YubiKey will be linked to your
account.{% endblocktrans %}</p>
{% endif %}
<form action="" method="post">{% csrf_token %}
{% include "two_factor/_wizard_forms.html" %}
{# hidden submit button to enable [enter] key #}
<input type="submit" value="" style="display:none" />
{% include "two_factor/_wizard_actions.html" %}
</form>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %}</h1>
<p class="subtitle">{% blocktrans trimmed %}Congratulations, you've successfully enabled two-factor
authentication.{% endblocktrans %}</p>
{% if not phone_methods %}
<p class="subtitle"><a href="{% url 'two_factor:profile' %}"
class="button">{% trans "Back to Account Security" %}</a></p>
{% else %}
<p class="subtitle">{% blocktrans trimmed %}However, it might happen that you don't have access to
your primary token device. To enable account recovery, add a phone
number.{% endblocktrans %}</p>
<a href="{% url 'two_factor:profile' %}"
class="float-right button">{% trans "Back to Account Security" %}</a>
<p class="subtitle"><a href="{% url 'two_factor:phone_create' %}"
class="button">{% trans "Add Phone Number" %}</a></p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% block title %}{% trans "Disable Two-factor Authentication" %}{% endblock %}</h1>
<p class="subtitle">{% blocktrans trimmed %}You are about to disable two-factor authentication. This
weakens your account security, are you sure?{% endblocktrans %}</p>
<form method="post">
{% csrf_token %}
<table>{{ form }}</table>
<button class="button"
type="submit">{% trans "Disable" %}</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,63 @@
{% extends "two_factor/_base.html" %}
{% load i18n %}
{% block content %}
<h1 class="title">{% block title %}{% trans "Account Security" %}{% endblock %}</h1>
{% if default_device %}
{% if default_device_type == 'TOTPDevice' %}
<p class="subtitle">{% trans "Tokens will be generated by your token generator." %}</p>
{% elif default_device_type == 'PhoneDevice' %}
<p class="subtitle">{% blocktrans with primary=default_device.generate_challenge_button_title %}Primary method: {{ primary }}{% endblocktrans %}</p>
{% elif default_device_type == 'RemoteYubikeyDevice' %}
<p class="subtitle">{% blocktrans %}Tokens will be generated by your YubiKey.{% endblocktrans %}</p>
{% endif %}
{% if available_phone_methods %}
<h2 class="title is-4">{% trans "Backup Phone Numbers" %}</h2>
<p class="subtitle">{% blocktrans trimmed %}If your primary method is not available, we are able to
send backup tokens to the phone numbers listed below.{% endblocktrans %}</p>
<ul>
{% for phone in backup_phones %}
<li>
{{ phone.generate_challenge_button_title }}
<form method="post" action="{% url 'two_factor:phone_delete' phone.id %}"
onsubmit="return confirm({% trans 'Are you sure?' %})">
{% csrf_token %}
<button class="button is-warning"
type="submit">{% trans "Unregister" %}</button>
</form>
</li>
{% endfor %}
</ul>
<p class="subtitle"><a href="{% url 'two_factor:phone_create' %}"
class="button">{% trans "Add Phone Number" %}</a></p>
{% endif %}
<h2 class="title is-4">{% trans "Backup Tokens" %}</h2>
<p class="subtitle">
{% blocktrans trimmed %}If you don't have any device with you, you can access
your account using backup tokens.{% endblocktrans %}
{% blocktrans trimmed count counter=backup_tokens %}
You have only one backup token remaining.
{% plural %}
You have {{ counter }} backup tokens remaining.
{% endblocktrans %}
</p>
<p class="subtitle"><a href="{% url 'two_factor:backup_tokens' %}"
class="button">{% trans "Show Codes" %}</a></p>
<h3 class="title is-5">{% trans "Disable Two-Factor Authentication" %}</h3>
<p class="subtitle">{% blocktrans trimmed %}However we strongly discourage you to do so, you can
also disable two-factor authentication for your account.{% endblocktrans %}</p>
<p class="subtitle"><a class="button" href="{% url 'two_factor:disable' %}">
{% trans "Disable Two-Factor Authentication" %}</a></p>
{% else %}
<p class="subtitle">{% blocktrans trimmed %}Two-factor authentication is not enabled for your
account. Enable two-factor authentication for enhanced account
security.{% endblocktrans %}</p>
<p class="subtitle"><a href="{% url 'two_factor:setup' %}" class="button">
{% trans "Enable Two-Factor Authentication" %}</a>
</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,7 @@
{% load i18n %}<?xml version="1.0" encoding="UTF-8" ?>
<Response>
<Gather timeout="15" numDigits="1" finishOnKey="">
<Say language="{{ locale }}">{% blocktrans %}Hi, this is {{ site_name }} calling. Press any key to continue.{% endblocktrans %}</Say>
</Gather>
<Say language="{{ locale }}">{% trans "You didnt press any keys. Good bye." %}</Say>
</Response>

View File

@@ -0,0 +1,5 @@
{% load i18n %}
{% blocktrans trimmed %}
Your OTP token is {{ token }}
{% endblocktrans %}

View File

@@ -0,0 +1,12 @@
{% load i18n %}<?xml version="1.0" encoding="UTF-8" ?>
<Response>
<Say language="{{ locale }}">{% trans "Your token is:" %}</Say>
<Pause>
{% for digit in token %} <Say language="{{ locale }}">{{ digit }}</Say>
<Pause>
{% endfor %} <Say language="{{ locale }}">{% trans "Repeat:" %}</Say>
<Pause>
{% for digit in token %} <Say language="{{ locale }}">{{ digit }}</Say>
<Pause>
{% endfor %} <Say language="{{ locale }}">{% trans "Good bye." %}</Say>
</Response>

View File

@@ -1,20 +0,0 @@
{% extends 'wm/widget.html' %}
{% load static %}
{% block widget_options %}
gs-w="10" gs-h="1" gs-y="10" gs-x="1"
{% endblock %}
{% block heading %}
Widget
{% endblock %}
{% block close_button %}
<i
class="fa-solid fa-xmark has-text-grey-light float-right"
onclick='grid.removeWidget("widget-{{ unique }}"); //grid.compact();'></i>
{% endblock %}
{% block panel_content %}
{% include 'window-content/main.html' %}
{% endblock %}

View File

@@ -1,44 +0,0 @@
<p class="title">This is a demo panel</p>
<div class="buttons">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'modal' %}"
hx-trigger="click"
hx-target="#modals-here"
class="button is-info">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-list"></i>
</span>
<span>Open modal</span>
</span>
</button>
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'widget' %}"
hx-trigger="click"
hx-target="#widgets-here"
class="button is-info">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-list"></i>
</span>
<span>Open widget</span>
</span>
</button>
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'window' %}"
hx-trigger="click"
hx-target="#items-here"
hx-swap="afterend"
class="button is-info">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-list"></i>
</span>
<span>Open window</span>
</span>
</button>
</div>

View File

@@ -1,9 +0,0 @@
{% extends 'wm/magnet.html' %}
{% block heading %}
Window
{% endblock %}
{% block panel_content %}
{% include 'window-content/main.html' %}
{% endblock %}

View File

@@ -1,20 +0,0 @@
{% load static %}
<script src="{% static 'modal.js' %}"></script>
{% block scripts %}
{% endblock %}
{% block styles %}
{% endblock %}
<div id="modal" class="modal is-active is-clipped">
<div class="modal-background"></div>
<div class="modal-content">
<div class="box">
{% block modal_content %}
{% include window_content %}
{% endblock %}
{% include 'partials/close-modal.html' %}
</div>
</div>
</div>

View File

@@ -1,6 +0,0 @@
{% extends "base.html" %}
{% block content %}
{% include window_content %}
{% endblock %}

View File

@@ -1,17 +0,0 @@
<nav class="panel">
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
{% block close_button %}
{% include 'partials/close-window.html' %}
{% endblock %}
{% block heading %}
{% endblock %}
</p>
<article class="panel-block is-active">
<div class="control">
{% block panel_content %}
{% endblock %}
</div>
</article>
</nav>

View File

@@ -1,37 +0,0 @@
<div id="widget">
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}gs-w="10" gs-h="1" gs-y="10" gs-x="1"{% endblock %}>
<div class="grid-stack-item-content">
<nav class="panel">
<p class="panel-heading" style="padding: .2em; line-height: .5em;">
<i class="fa-solid fa-arrows-up-down-left-right has-text-grey-light"></i>
{% block close_button %}
{% include 'partials/close-widget.html' %}
{% endblock %}
<i
class="fa-solid fa-arrows-minimize has-text-grey-light float-right"
onclick='grid.compact();'></i>
{% block heading %}
{{ title }}
{% endblock %}
</p>
<article class="panel-block is-active">
<div class="control">
{% block panel_content %}
{% include window_content %}
{% endblock %}
</div>
</article>
</nav>
</div>
</div>
</div>
<script>
{% block custom_script %}
{% endblock %}
var widget_event = new Event('load-widget');
document.dispatchEvent(widget_event);
</script>
{% block custom_end %}
{% endblock %}

View File

@@ -1,10 +0,0 @@
<magnet-block attract-distance="10" align-to="outer|center" class="floating-window">
{% extends 'wm/panel.html' %}
{% block heading %}
{{ title }}
{% endblock %}
{% block panel_content %}
{% include window_content %}
{% endblock %}
</magnet-block>

0
core/tests/__init__.py Normal file
View File

View File

@@ -1,6 +1,6 @@
import logging import logging
import stripe # import stripe
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse from django.http import JsonResponse
@@ -10,8 +10,7 @@ from django.views import View
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView
from core.forms import NewUserForm from core.forms import NewUserForm
from core.lib.products import assemble_plan_map from core.lib.notify import raw_sendmsg
from core.models import Plan, Session
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,81 +24,22 @@ class Home(View):
return render(request, self.template_name) return render(request, self.template_name)
class Billing(LoginRequiredMixin, View):
template_name = "billing.html"
def get(self, request):
if not settings.STRIPE_ENABLED:
return redirect(reverse("home"))
plans = Plan.objects.all()
user_plans = request.user.plans.all()
context = {"plans": plans, "user_plans": user_plans}
return render(request, self.template_name, context)
class Order(LoginRequiredMixin, View):
def get(self, request, plan_name):
if not settings.STRIPE_ENABLED:
return redirect(reverse("home"))
plan = Plan.objects.get(name=plan_name)
try:
cast = {
"payment_method_types": settings.ALLOWED_PAYMENT_METHODS,
"mode": "subscription",
"customer": request.user.stripe_id,
"line_items": assemble_plan_map(product_id_filter=plan.product_id),
"success_url": request.build_absolute_uri(reverse("success")),
"cancel_url": request.build_absolute_uri(reverse("cancel")),
}
if request.user.is_superuser:
cast["discounts"] = [{"coupon": settings.STRIPE_ADMIN_COUPON}]
session = stripe.checkout.Session.create(**cast)
Session.objects.create(user=request.user, session=session.id)
return redirect(session.url)
# return JsonResponse({'id': session.id})
except Exception as e:
# Raise a server error
return JsonResponse({"error": str(e)}, status=500)
class Cancel(LoginRequiredMixin, View):
def get(self, request, plan_name):
if not settings.STRIPE_ENABLED:
return redirect(reverse("home"))
plan = Plan.objects.get(name=plan_name)
try:
subscriptions = stripe.Subscription.list(
customer=request.user.stripe_id, price=plan.product_id
)
for subscription in subscriptions["data"]:
items = subscription["items"]["data"]
for item in items:
stripe.Subscription.delete(item["subscription"])
return render(request, "subscriptioncancel.html", {"plan": plan})
# return JsonResponse({'id': session.id})
except Exception as e:
# Raise a server error
logging.error(f"Error cancelling subscription for user: {e}")
return JsonResponse({"error": "True"}, status=500)
class Signup(CreateView): class Signup(CreateView):
form_class = NewUserForm form_class = NewUserForm
success_url = reverse_lazy("login") success_url = reverse_lazy("two_factor:login")
template_name = "registration/signup.html" template_name = "registration/signup.html"
def form_valid(self, form):
"""If the form is valid, save the associated model."""
self.object = form.save()
raw_sendmsg(
f"New user signup: {self.object.username} - {self.object.email}",
title="New user",
topic=settings.NOTIFY_TOPIC,
)
return super().form_valid(form)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if not settings.REGISTRATION_OPEN: if not settings.REGISTRATION_OPEN:
return render(request, "registration/registration_closed.html") return render(request, "registration/registration_closed.html")
super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
class Portal(LoginRequiredMixin, View):
def get(self, request):
if not settings.STRIPE_ENABLED:
return redirect(reverse("home"))
session = stripe.billing_portal.Session.create(
customer=request.user.stripe_id,
return_url=request.build_absolute_uri(reverse("billing")),
)
return redirect(session.url)

View File

@@ -1,103 +0,0 @@
import logging
from datetime import datetime
import stripe
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from rest_framework.parsers import JSONParser
from rest_framework.views import APIView
from core.models import Plan, Session, User
logger = logging.getLogger(__name__)
class Callback(APIView):
parser_classes = [JSONParser]
@csrf_exempt
def post(self, request):
payload = request.body
sig_header = request.META["HTTP_STRIPE_SIGNATURE"]
try:
stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_ENDPOINT_SECRET
)
except ValueError:
# Invalid payload
logger.error("Invalid payload")
return HttpResponse(status=400)
except stripe.error.SignatureVerificationError:
# Invalid signature
logger.error("Invalid signature")
return HttpResponse(status=400)
if request.data is None:
return JsonResponse({"success": False}, status=500)
if "type" in request.data.keys():
rtype = request.data["type"]
if rtype == "checkout.session.completed":
session = request.data["data"]["object"]["id"]
subscription_id = request.data["data"]["object"]["subscription"]
session_map = Session.objects.get(session=session)
if not session_map:
return JsonResponse({"success": False}, status=500)
user = session_map.user
session_map.subscription_id = subscription_id
session_map.save()
if rtype == "customer.subscription.updated":
stripe_id = request.data["data"]["object"]["customer"]
if not stripe_id:
logging.error("No stripe id")
return JsonResponse({"success": False}, status=500)
user = User.objects.get(stripe_id=stripe_id)
# ssubscription_active
subscription_id = request.data["data"]["object"]["id"]
sessions = Session.objects.filter(user=user)
session = None
for session_iter in sessions:
if session_iter.subscription_id == subscription_id:
session = session_iter
if not session:
logging.error(
f"No session found for subscription id {subscription_id}"
)
return JsonResponse({"success": False}, status=500)
# query Session objects
# iterate and check against product_id
session.request = request.data["request"]["id"]
product_id = request.data["data"]["object"]["plan"]["id"]
plan = Plan.objects.get(product_id=product_id)
if not plan:
logging.error(f"Plan not found: {product_id}")
return JsonResponse({"success": False}, status=500)
session.plan = plan
session.save()
elif rtype == "payment_intent.succeeded":
customer = request.data["data"]["object"]["customer"]
user = User.objects.get(stripe_id=customer)
if not user:
logging.error(f"No user found for customer: {customer}")
return JsonResponse({"success": False}, status=500)
session = Session.objects.get(request=request.data["request"]["id"])
user.plans.add(session.plan)
user.last_payment = datetime.utcnow()
user.save()
elif rtype == "customer.subscription.deleted":
customer = request.data["data"]["object"]["customer"]
user = User.objects.get(stripe_id=customer)
if not user:
logging.error(f"No user found for customer {customer}")
return JsonResponse({"success": False}, status=500)
product_id = request.data["data"]["object"]["plan"]["id"]
plan = Plan.objects.get(product_id=product_id)
user.plans.remove(plan)
user.save()
else:
return JsonResponse({"success": False}, status=500)
return JsonResponse({"success": True})

View File

@@ -1,26 +0,0 @@
import uuid
from django.shortcuts import render
from django.views import View
class DemoModal(View):
template_name = "modals/modal.html"
def get(self, request):
return render(request, self.template_name)
class DemoWidget(View):
template_name = "widgets/widget.html"
def get(self, request):
unique = str(uuid.uuid4())[:8]
return render(request, self.template_name, {"unique": unique})
class DemoWindow(View):
template_name = "windows/window.html"
def get(self, request):
return render(request, self.template_name)

View File

@@ -0,0 +1,30 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectUpdate
from core.forms import NotificationSettingsForm
from core.models import NotificationSettings
# Notifications - we create a new notification settings object if there isn't one
# Hence, there is only an update view, not a create view.
class NotificationsUpdate(LoginRequiredMixin, ObjectUpdate):
model = NotificationSettings
form_class = NotificationSettingsForm
page_title = "Update your notification settings"
page_subtitle = (
"At least the topic must be set if you want to receive notifications."
)
submit_url_name = "notifications_update"
submit_url_args = ["type"]
pk_required = False
hide_cancel = True
def get_object(self, **kwargs):
notification_settings, _ = NotificationSettings.objects.get_or_create(
user=self.request.user
)
return notification_settings

0
core/views/signal.py Normal file
View File

View File

@@ -9,26 +9,140 @@ services:
args: args:
OPERATION: ${OPERATION} OPERATION: ${OPERATION}
volumes: volumes:
- ${PORTAINER_GIT_DIR}:/code - ${REPO_DIR}:/code
- ${PORTAINER_GIT_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini - ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
- ${APP_DATABASE_FILE}:/conf/db.sqlite3 - ${APP_DATABASE_FILE}:/conf/db.sqlite3
- app_static:${STATIC_ROOT} - type: bind
#ports: source: /code/vrun
# - "8000:8000" # uwsgi socket target: /var/run
env_file: environment:
- stack.env APP_PORT: "${APP_PORT}"
volumes_from: REPO_DIR: "${REPO_DIR}"
- tmp APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
DOMAIN: "${DOMAIN}"
URL: "${URL}"
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
DEBUG: "${DEBUG}"
SECRET_KEY: "${SECRET_KEY}"
STATIC_ROOT: "${STATIC_ROOT}"
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}"
depends_on: depends_on:
# redis: redis:
# condition: service_healthy condition: service_healthy
migration: migration:
condition: service_started condition: service_started
collectstatic: collectstatic:
condition: service_started condition: service_started
networks: # deploy:
- default # resources:
- xf # limits:
# cpus: '0.1'
# memory: 0.25G
#network_mode: host
signal-cli-rest-api:
image: bbernhard/signal-cli-rest-api:latest
container_name: signal
environment:
- MODE=normal #supported modes: json-rpc, native, normal
- AUTO_RECEIVE_SCHEDULE=0 22 * * *
# ports:
# - "8080:8080"
volumes:
- "./signal-cli-config:/home/.local/share/signal-cli"
processing:
image: xf/envelope:prod
container_name: processing_envelope
build:
context: .
args:
OPERATION: ${OPERATION}
command: sh -c '. /venv/bin/activate && python manage.py processing'
volumes:
- ${REPO_DIR}:/code
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
- type: bind
source: /code/vrun
target: /var/run
environment:
APP_PORT: "${APP_PORT}"
REPO_DIR: "${REPO_DIR}"
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
DOMAIN: "${DOMAIN}"
URL: "${URL}"
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
DEBUG: "${DEBUG}"
SECRET_KEY: "${SECRET_KEY}"
STATIC_ROOT: "${STATIC_ROOT}"
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}"
depends_on:
redis:
condition: service_healthy
migration:
condition: service_started
collectstatic:
condition: service_started
# deploy:
# resources:
# limits:
# cpus: '0.25'
# memory: 0.25G
#network_mode: host
scheduling:
image: xf/envelope:prod
container_name: scheduling_envelope
build:
context: .
args:
OPERATION: ${OPERATION}
command: sh -c '. /venv/bin/activate && python manage.py scheduling'
volumes:
- ${REPO_DIR}:/code
- ${REPO_DIR}/docker/uwsgi.ini:/conf/uwsgi.ini
- ${APP_DATABASE_FILE}:/conf/db.sqlite3
- type: bind
source: /code/vrun
target: /var/run
environment:
APP_PORT: "${APP_PORT}"
REPO_DIR: "${REPO_DIR}"
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
DOMAIN: "${DOMAIN}"
URL: "${URL}"
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
DEBUG: "${DEBUG}"
SECRET_KEY: "${SECRET_KEY}"
STATIC_ROOT: "${STATIC_ROOT}"
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}"
depends_on:
redis:
condition: service_healthy
migration:
condition: service_started
collectstatic:
condition: service_started
# deploy:
# resources:
# limits:
# cpus: '0.25'
# memory: 0.25G
#network_mode: host
migration: migration:
image: xf/envelope:prod image: xf/envelope:prod
@@ -39,11 +153,32 @@ services:
OPERATION: ${OPERATION} OPERATION: ${OPERATION}
command: sh -c '. /venv/bin/activate && python manage.py migrate --noinput' command: sh -c '. /venv/bin/activate && python manage.py migrate --noinput'
volumes: volumes:
- ${PORTAINER_GIT_DIR}:/code - ${REPO_DIR}:/code
- ${APP_DATABASE_FILE}:/conf/db.sqlite3 - ${APP_DATABASE_FILE}:/conf/db.sqlite3
- app_static:${STATIC_ROOT} - type: bind
env_file: source: /code/vrun
- stack.env target: /var/run
environment:
APP_PORT: "${APP_PORT}"
REPO_DIR: "${REPO_DIR}"
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
DOMAIN: "${DOMAIN}"
URL: "${URL}"
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
DEBUG: "${DEBUG}"
SECRET_KEY: "${SECRET_KEY}"
STATIC_ROOT: "${STATIC_ROOT}"
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}"
# deploy:
# resources:
# limits:
# cpus: '0.25'
# memory: 0.25G
#network_mode: host
collectstatic: collectstatic:
image: xf/envelope:prod image: xf/envelope:prod
@@ -54,72 +189,54 @@ services:
OPERATION: ${OPERATION} OPERATION: ${OPERATION}
command: sh -c '. /venv/bin/activate && python manage.py collectstatic --noinput' command: sh -c '. /venv/bin/activate && python manage.py collectstatic --noinput'
volumes: volumes:
- ${PORTAINER_GIT_DIR}:/code - ${REPO_DIR}:/code
- ${APP_DATABASE_FILE}:/conf/db.sqlite3 - ${APP_DATABASE_FILE}:/conf/db.sqlite3
- app_static:${STATIC_ROOT} - type: bind
env_file: source: /code/vrun
- stack.env target: /var/run
environment:
APP_PORT: "${APP_PORT}"
REPO_DIR: "${REPO_DIR}"
APP_LOCAL_SETTINGS: "${APP_LOCAL_SETTINGS}"
APP_DATABASE_FILE: "${APP_DATABASE_FILE}"
DOMAIN: "${DOMAIN}"
URL: "${URL}"
ALLOWED_HOSTS: "${ALLOWED_HOSTS}"
NOTIFY_TOPIC: "${NOTIFY_TOPIC}"
CSRF_TRUSTED_ORIGINS: "${CSRF_TRUSTED_ORIGINS}"
DEBUG: "${DEBUG}"
SECRET_KEY: "${SECRET_KEY}"
STATIC_ROOT: "${STATIC_ROOT}"
REGISTRATION_OPEN: "${REGISTRATION_OPEN}"
OPERATION: "${OPERATION}"
# deploy:
# resources:
# limits:
# cpus: '0.25'
# memory: 0.25G
#network_mode: host
nginx: redis:
image: nginx:latest image: redis
container_name: nginx_envelope container_name: redis_envelope
ports: command: redis-server /etc/redis.conf
- ${APP_PORT}:9999
ulimits:
nproc: 65535
nofile:
soft: 65535
hard: 65535
volumes: volumes:
- ${PORTAINER_GIT_DIR}:/code - ${REPO_DIR}/docker/redis.conf:/etc/redis.conf
- ${PORTAINER_GIT_DIR}/docker/nginx/conf.d/${OPERATION}.conf:/etc/nginx/conf.d/default.conf - envelope_redis_data:/data
- app_static:${STATIC_ROOT} - type: bind
volumes_from: source: /code/vrun
- tmp target: /var/run
networks: healthcheck:
- default test: "redis-cli ping"
- xf interval: 2s
depends_on: timeout: 2s
app: retries: 15
condition: service_started # deploy:
# resources:
# volumes_from: # limits:
# - tmp # cpus: '0.25'
# depends_on: # memory: 0.25G
# redis: #network_mode: host
# condition: service_healthy
tmp:
image: busybox
container_name: tmp_envelope
command: chmod -R 777 /var/run/socks
volumes:
- /var/run/socks
# redis:
# image: redis
# command: redis-server /etc/redis.conf
# ulimits:
# nproc: 65535
# nofile:
# soft: 65535
# hard: 65535
# volumes:
# - ${PORTAINER_GIT_DIR}/docker/redis.conf:/etc/redis.conf
# - redis_data:/data
# volumes_from:
# - tmp
# healthcheck:
# test: "redis-cli -s /var/run/redis/redis.sock ping"
# interval: 2s
# timeout: 2s
# retries: 15
networks:
default:
driver: bridge
xf:
external: true
volumes: volumes:
app_static: {} envelope_redis_data: {}

View File

@@ -1,7 +1,6 @@
upstream django { upstream django {
#server app:8000; #server app:8000;
#server unix:///var/run/socks/app.sock; server unix:///var/run/uwsgi-envelope.sock;
server app:8000;
} }
server { server {
@@ -14,10 +13,12 @@ server {
} }
location / { location / {
proxy_pass http://django; include /etc/nginx/uwsgi_params; # the uwsgi_params file you installed
proxy_set_header X-Real-IP $remote_addr; uwsgi_pass django;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; uwsgi_param Host $host;
proxy_set_header Host $host; uwsgi_param X-Real-IP $remote_addr;
uwsgi_param X-Forwarded-For $proxy_add_x_forwarded_for;
uwsgi_param X-Forwarded-Proto $http_x_forwarded_proto;
} }
} }

View File

@@ -1,2 +1,3 @@
unixsocket /var/run/redis/redis.sock unixsocket /var/run/envelope-redis.sock
unixsocketperm 777 unixsocketperm 777
port 0

View File

@@ -4,9 +4,19 @@ module=app.wsgi:application
env=DJANGO_SETTINGS_MODULE=app.settings env=DJANGO_SETTINGS_MODULE=app.settings
master=1 master=1
pidfile=/tmp/project-master.pid pidfile=/tmp/project-master.pid
socket=0.0.0.0:8000 #socket=0.0.0.0:8000
socket=/var/run/uwsgi-envelope.sock
# socket 777
chmod-socket=777
harakiri=20 harakiri=20
max-requests=100000 #max-requests=100000
# Set a lower value for max-requests to prevent memory leaks from building up over time
max-requests=1000
# Ensure old worker processes are cleaned up properly
reload-on-as=512
reload-on-rss=256
vacuum=1 vacuum=1
home=/venv home=/venv
processes=12 processes=4
threads=2
log-level=debug

View File

@@ -2,16 +2,31 @@ wheel
uwsgi uwsgi
django django
pre-commit pre-commit
django-crispy-forms django-crispy-forms==1.14.0
crispy-bulma crispy-bulma
stripe # stripe
django-rest-framework django-rest-framework
uvloop uvloop
uvicorn[standard]
gunicorn
django-htmx django-htmx
cryptography cryptography
django-debug-toolbar django-debug-toolbar
django-debug-toolbar-template-profiler django-debug-toolbar-template-profiler
orjson orjson
msgpack
apscheduler
watchfiles watchfiles
django-otp
django-two-factor-auth
django-otp-yubikey
phonenumbers
qrcode
pydantic
# glom
git+https://git.zm.is/XF/django-crud-mixins
# pyroscope-io
# For caching
redis
hiredis
django-cachalot
django_redis
requests

15
stack.env.example Normal file
View File

@@ -0,0 +1,15 @@
APP_PORT=5006
REPO_DIR=.
APP_LOCAL_SETTINGS=./app/local_settings.py
APP_DATABASE_FILE=./db.sqlite3
DOMAIN=example.com
URL=https://example.com
ALLOWED_HOSTS=example.com
NOTIFY_TOPIC=example-topic
CSRF_TRUSTED_ORIGINS=https://example.com
DEBUG=y
SECRET_KEY=
STATIC_ROOT=/code/static
REGISTRATION_OPEN=0
OPERATION=uwsgi
BILLING_ENABLED=0