4.56.3
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m15s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m45s
Build-Release-Image / Merge-Images (push) Successful in 15s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 21s
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m15s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m45s
Build-Release-Image / Merge-Images (push) Successful in 15s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 21s
This commit is contained in:
parent
a62b43b7c4
commit
25834e8f61
@ -7,8 +7,4 @@ If you want be up to date on security patches, make sure your SimpleLogin image
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io.
|
If you want to report a vulnerability, please take a look at our bug bounty program at https://proton.me/security/bug-bounty.
|
||||||
We will review the potential threat and fix it as fast as we can.
|
|
||||||
|
|
||||||
We are incredibly thankful for people who disclose vulnerabilities, unfortunately we do not have a bounty program in place yet.
|
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ from app.events.auth_event import LoginEvent
|
|||||||
from app.extensions import limiter
|
from app.extensions import limiter
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import User
|
from app.models import User
|
||||||
|
from app.pw_models import PasswordOracle
|
||||||
from app.utils import sanitize_email, sanitize_next_url, canonicalize_email
|
from app.utils import sanitize_email, sanitize_next_url, canonicalize_email
|
||||||
|
|
||||||
|
|
||||||
@ -43,6 +44,13 @@ def login():
|
|||||||
user = User.get_by(email=email) or User.get_by(email=canonical_email)
|
user = User.get_by(email=email) or User.get_by(email=canonical_email)
|
||||||
|
|
||||||
if not user or not user.check_password(form.password.data):
|
if not user or not user.check_password(form.password.data):
|
||||||
|
if not user:
|
||||||
|
# Do the hash to avoid timing attacks nevertheless
|
||||||
|
dummy_pw = PasswordOracle()
|
||||||
|
dummy_pw.password = (
|
||||||
|
"$2b$12$ZWqpL73h4rGNfLkJohAFAu0isqSw/bX9p/tzpbWRz/To5FAftaW8u"
|
||||||
|
)
|
||||||
|
dummy_pw.check_password(form.password.data)
|
||||||
# Trigger rate limiter
|
# Trigger rate limiter
|
||||||
g.deduct_limit = True
|
g.deduct_limit = True
|
||||||
form.password.data = None
|
form.password.data = None
|
||||||
|
@ -91,6 +91,7 @@ def create_contact(
|
|||||||
alias_id = alias.id
|
alias_id = alias.id
|
||||||
try:
|
try:
|
||||||
flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0
|
flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0
|
||||||
|
is_invalid_email = email == ""
|
||||||
contact = Contact.create(
|
contact = Contact.create(
|
||||||
user_id=alias.user_id,
|
user_id=alias.user_id,
|
||||||
alias_id=alias.id,
|
alias_id=alias.id,
|
||||||
@ -100,9 +101,10 @@ def create_contact(
|
|||||||
mail_from=mail_from,
|
mail_from=mail_from,
|
||||||
automatic_created=automatic_created,
|
automatic_created=automatic_created,
|
||||||
flags=flags,
|
flags=flags,
|
||||||
invalid_email=email == "",
|
invalid_email=is_invalid_email,
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
|
contact_id = contact.id
|
||||||
if automatic_created:
|
if automatic_created:
|
||||||
trail = ". Automatically created"
|
trail = ". Automatically created"
|
||||||
else:
|
else:
|
||||||
@ -110,11 +112,11 @@ def create_contact(
|
|||||||
emit_alias_audit_log(
|
emit_alias_audit_log(
|
||||||
alias=alias,
|
alias=alias,
|
||||||
action=AliasAuditLogAction.CreateContact,
|
action=AliasAuditLogAction.CreateContact,
|
||||||
message=f"Created contact {contact.id} ({contact.email}){trail}",
|
message=f"Created contact {contact_id} ({email}){trail}",
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
LOG.d(
|
LOG.d(
|
||||||
f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}"
|
f"Created contact {contact} for alias {alias} with email {email} invalid_email={is_invalid_email}"
|
||||||
)
|
)
|
||||||
return ContactCreateResult(contact, created=True, error=None)
|
return ContactCreateResult(contact, created=True, error=None)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import secrets
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
from flask import (
|
from flask import (
|
||||||
render_template,
|
render_template,
|
||||||
@ -163,7 +165,7 @@ def send_reset_password_email(user):
|
|||||||
"""
|
"""
|
||||||
# the activation code is valid for 1h
|
# the activation code is valid for 1h
|
||||||
reset_password_code = ResetPasswordCode.create(
|
reset_password_code = ResetPasswordCode.create(
|
||||||
user_id=user.id, code=random_string(60)
|
user_id=user.id, code=secrets.token_urlsafe(32)
|
||||||
)
|
)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import arrow
|
||||||
from flask import render_template, flash, redirect, url_for
|
from flask import render_template, flash, redirect, url_for
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
@ -7,6 +8,8 @@ from app.config import ADMIN_EMAIL
|
|||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.email_utils import send_email
|
from app.email_utils import send_email
|
||||||
|
from app.events.event_dispatcher import EventDispatcher
|
||||||
|
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
|
||||||
from app.models import LifetimeCoupon
|
from app.models import LifetimeCoupon
|
||||||
|
|
||||||
|
|
||||||
@ -40,6 +43,14 @@ def lifetime_licence():
|
|||||||
current_user.lifetime_coupon_id = coupon.id
|
current_user.lifetime_coupon_id = coupon.id
|
||||||
if coupon.paid:
|
if coupon.paid:
|
||||||
current_user.paid_lifetime = True
|
current_user.paid_lifetime = True
|
||||||
|
EventDispatcher.send_event(
|
||||||
|
user=current_user,
|
||||||
|
content=EventContent(
|
||||||
|
user_plan_change=UserPlanChanged(
|
||||||
|
plan_end_time=arrow.get("2100-01-01").timestamp
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
# notify admin
|
# notify admin
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import secrets
|
import secrets
|
||||||
import random
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import arrow
|
import arrow
|
||||||
@ -233,7 +232,7 @@ def generate_activation_code(
|
|||||||
if config.MAILBOX_VERIFICATION_OVERRIDE_CODE:
|
if config.MAILBOX_VERIFICATION_OVERRIDE_CODE:
|
||||||
code = config.MAILBOX_VERIFICATION_OVERRIDE_CODE
|
code = config.MAILBOX_VERIFICATION_OVERRIDE_CODE
|
||||||
else:
|
else:
|
||||||
code = "{:06d}".format(random.randint(1, 999999))
|
code = "{:06d}".format(secrets.randbelow(1000000))[:6]
|
||||||
else:
|
else:
|
||||||
code = secrets.token_urlsafe(16)
|
code = secrets.token_urlsafe(16)
|
||||||
return MailboxActivation.create(
|
return MailboxActivation.create(
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import random
|
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
@ -32,8 +31,9 @@ def random_words(words: int = 2, numbers: int = 0):
|
|||||||
fields = [secrets.choice(_words) for i in range(words)]
|
fields = [secrets.choice(_words) for i in range(words)]
|
||||||
|
|
||||||
if numbers > 0:
|
if numbers > 0:
|
||||||
digits = "".join([str(random.randint(0, 9)) for i in range(numbers)])
|
digits = [n for n in range(10)]
|
||||||
return "_".join(fields) + digits
|
suffix = "".join([str(secrets.choice(digits)) for i in range(numbers)])
|
||||||
|
return "_".join(fields) + suffix
|
||||||
else:
|
else:
|
||||||
return "_".join(fields)
|
return "_".join(fields)
|
||||||
|
|
||||||
|
39
app/cron.py
39
app/cron.py
@ -286,8 +286,16 @@ def notify_manual_sub_end():
|
|||||||
|
|
||||||
def poll_apple_subscription():
|
def poll_apple_subscription():
|
||||||
"""Poll Apple API to update AppleSubscription"""
|
"""Poll Apple API to update AppleSubscription"""
|
||||||
# todo: only near the end of the subscription
|
for apple_sub in (
|
||||||
for apple_sub in AppleSubscription.all():
|
AppleSubscription.filter(
|
||||||
|
AppleSubscription.expires_date < arrow.now().shift(days=15)
|
||||||
|
)
|
||||||
|
.enable_eagerloads(False)
|
||||||
|
.yield_per(100)
|
||||||
|
):
|
||||||
|
if not apple_sub.is_valid():
|
||||||
|
# Subscription is not valid anymore and hasn't been renewed
|
||||||
|
continue
|
||||||
if not apple_sub.product_id:
|
if not apple_sub.product_id:
|
||||||
LOG.d("Ignore %s", apple_sub)
|
LOG.d("Ignore %s", apple_sub)
|
||||||
continue
|
continue
|
||||||
@ -900,6 +908,24 @@ def check_mailbox_valid_pgp_keys():
|
|||||||
|
|
||||||
|
|
||||||
def check_custom_domain():
|
def check_custom_domain():
|
||||||
|
# Delete custom domains that haven't been verified in a month
|
||||||
|
for custom_domain in (
|
||||||
|
CustomDomain.filter(
|
||||||
|
CustomDomain.verified == False, # noqa: E712
|
||||||
|
CustomDomain.created_at < arrow.now().shift(months=-1),
|
||||||
|
)
|
||||||
|
.enable_eagerloads(False)
|
||||||
|
.yield_per(100)
|
||||||
|
):
|
||||||
|
alias_count = Alias.filter(Alias.custom_domain_id == custom_domain.id).count()
|
||||||
|
if alias_count > 0:
|
||||||
|
LOG.warn(
|
||||||
|
f"Custom Domain {custom_domain} has {alias_count} aliases. Won't delete"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
LOG.i(f"Deleting unverified old custom domain {custom_domain}")
|
||||||
|
CustomDomain.delete(custom_domain.id)
|
||||||
|
|
||||||
LOG.d("Check verified domain for DNS issues")
|
LOG.d("Check verified domain for DNS issues")
|
||||||
|
|
||||||
for custom_domain in CustomDomain.filter_by(verified=True): # type: CustomDomain
|
for custom_domain in CustomDomain.filter_by(verified=True): # type: CustomDomain
|
||||||
@ -971,7 +997,7 @@ def delete_expired_tokens():
|
|||||||
LOG.d("Delete api to cookie tokens older than %s, nb row %s", max_time, nb_row)
|
LOG.d("Delete api to cookie tokens older than %s, nb row %s", max_time, nb_row)
|
||||||
|
|
||||||
|
|
||||||
async def _hibp_check(api_key, queue):
|
async def _hibp_check(api_key: str, queue: asyncio.Queue):
|
||||||
"""
|
"""
|
||||||
Uses a single API key to check the queue as fast as possible.
|
Uses a single API key to check the queue as fast as possible.
|
||||||
|
|
||||||
@ -990,11 +1016,16 @@ async def _hibp_check(api_key, queue):
|
|||||||
if not alias:
|
if not alias:
|
||||||
continue
|
continue
|
||||||
user = alias.user
|
user = alias.user
|
||||||
if user.disabled or not user.is_paid():
|
if user.disabled or not user.is_premium():
|
||||||
# Mark it as hibp done to skip it as if it had been checked
|
# Mark it as hibp done to skip it as if it had been checked
|
||||||
alias.hibp_last_check = arrow.utcnow()
|
alias.hibp_last_check = arrow.utcnow()
|
||||||
Session.commit()
|
Session.commit()
|
||||||
continue
|
continue
|
||||||
|
if alias.flags & Alias.FLAG_PARTNER_CREATED > 0:
|
||||||
|
# Mark as hibp done
|
||||||
|
alias.hibp_last_check = arrow.utcnow()
|
||||||
|
Session.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
LOG.d("Checking HIBP for %s", alias)
|
LOG.d("Checking HIBP for %s", alias)
|
||||||
|
|
||||||
|
@ -16,13 +16,25 @@ jobs:
|
|||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
schedule: "15 2 * * *"
|
schedule: "15 2 * * *"
|
||||||
captureStderr: true
|
captureStderr: true
|
||||||
|
onFailure:
|
||||||
|
retry:
|
||||||
|
maximumRetries: 10
|
||||||
|
initialDelay: 1
|
||||||
|
maximumDelay: 30
|
||||||
|
backoffMultiplier: 2
|
||||||
|
|
||||||
- name: SimpleLogin HIBP check
|
- name: SimpleLogin HIBP check
|
||||||
command: python /code/cron.py -j check_hibp
|
command: python /code/cron.py -j check_hibp
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
schedule: "15 3 * * *"
|
schedule: "16 */4 * * *"
|
||||||
captureStderr: true
|
captureStderr: true
|
||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
|
onFailure:
|
||||||
|
retry:
|
||||||
|
maximumRetries: 10
|
||||||
|
initialDelay: 1
|
||||||
|
maximumDelay: 30
|
||||||
|
backoffMultiplier: 2
|
||||||
|
|
||||||
- name: SimpleLogin Notify HIBP breaches
|
- name: SimpleLogin Notify HIBP breaches
|
||||||
command: python /code/cron.py -j notify_hibp
|
command: python /code/cron.py -j notify_hibp
|
||||||
@ -31,6 +43,7 @@ jobs:
|
|||||||
captureStderr: true
|
captureStderr: true
|
||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
|
|
||||||
|
|
||||||
- name: SimpleLogin Delete Logs
|
- name: SimpleLogin Delete Logs
|
||||||
command: python /code/cron.py -j delete_logs
|
command: python /code/cron.py -j delete_logs
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
|
@ -177,7 +177,9 @@ from init_app import load_pgp_public_keys
|
|||||||
from server import create_light_app
|
from server import create_light_app
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Contact:
|
def get_or_create_contact(
|
||||||
|
from_header: str, mail_from: str, alias: Alias
|
||||||
|
) -> Optional[Contact]:
|
||||||
"""
|
"""
|
||||||
contact_from_header is the RFC 2047 format FROM header
|
contact_from_header is the RFC 2047 format FROM header
|
||||||
"""
|
"""
|
||||||
@ -208,6 +210,8 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
|||||||
automatic_created=True,
|
automatic_created=True,
|
||||||
from_partner=False,
|
from_partner=False,
|
||||||
)
|
)
|
||||||
|
if contact_result.error:
|
||||||
|
LOG.w(f"Error creating contact: {contact_result.error.value}")
|
||||||
return contact_result.contact
|
return contact_result.contact
|
||||||
|
|
||||||
|
|
||||||
@ -558,7 +562,7 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
|||||||
|
|
||||||
if not user.is_active():
|
if not user.is_active():
|
||||||
LOG.w(f"User {user} has been soft deleted")
|
LOG.w(f"User {user} has been soft deleted")
|
||||||
return False, status.E502
|
return [(False, status.E502)]
|
||||||
|
|
||||||
if not user.can_send_or_receive():
|
if not user.can_send_or_receive():
|
||||||
LOG.i(f"User {user} cannot receive emails")
|
LOG.i(f"User {user} cannot receive emails")
|
||||||
@ -579,6 +583,8 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
|||||||
from_header = get_header_unicode(msg[headers.FROM])
|
from_header = get_header_unicode(msg[headers.FROM])
|
||||||
LOG.d("Create or get contact for from_header:%s", from_header)
|
LOG.d("Create or get contact for from_header:%s", from_header)
|
||||||
contact = get_or_create_contact(from_header, envelope.mail_from, alias)
|
contact = get_or_create_contact(from_header, envelope.mail_from, alias)
|
||||||
|
if not contact:
|
||||||
|
return [(False, status.E504)]
|
||||||
alias = (
|
alias = (
|
||||||
contact.alias
|
contact.alias
|
||||||
) # In case the Session was closed in the get_or_create we re-fetch the alias
|
) # In case the Session was closed in the get_or_create we re-fetch the alias
|
||||||
|
60
app/oneshot/send_lifetime_user_events.py
Normal file
60
app/oneshot/send_lifetime_user_events.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from app.events.event_dispatcher import EventDispatcher
|
||||||
|
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
|
||||||
|
from app.models import PartnerUser
|
||||||
|
from app.db import Session
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="Backfill alias", description="Send lifetime users to proton"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-s", "--start_pu_id", default=0, type=int, help="Initial partner_user_id"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-e", "--end_pu_id", default=0, type=int, help="Last partner_user_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
pu_id_start = args.start_pu_id
|
||||||
|
max_pu_id = args.end_pu_id
|
||||||
|
if max_pu_id == 0:
|
||||||
|
max_pu_id = Session.query(func.max(PartnerUser.id)).scalar()
|
||||||
|
|
||||||
|
print(f"Checking partner user {pu_id_start} to {max_pu_id}")
|
||||||
|
step = 100
|
||||||
|
done = 0
|
||||||
|
start_time = time.time()
|
||||||
|
with_lifetime = 0
|
||||||
|
for batch_start in range(pu_id_start, max_pu_id, step):
|
||||||
|
partner_users = (
|
||||||
|
Session.query(PartnerUser).filter(
|
||||||
|
PartnerUser.id >= batch_start, PartnerUser.id < batch_start + step
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for partner_user in partner_users:
|
||||||
|
done += 1
|
||||||
|
if not partner_user.user.lifetime:
|
||||||
|
continue
|
||||||
|
with_lifetime += 1
|
||||||
|
event = UserPlanChanged(plan_end_time=arrow.get("2100-01-01").timestamp)
|
||||||
|
EventDispatcher.send_event(
|
||||||
|
partner_user.user, EventContent(user_plan_change=event)
|
||||||
|
)
|
||||||
|
Session.flush()
|
||||||
|
Session.commit()
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
last_batch_id = batch_start + step
|
||||||
|
time_per_alias = elapsed / (last_batch_id)
|
||||||
|
remaining = max_pu_id - last_batch_id
|
||||||
|
time_remaining = remaining / time_per_alias
|
||||||
|
hours_remaining = time_remaining / 60.0
|
||||||
|
print(
|
||||||
|
f"\PartnerUser {batch_start}/{max_pu_id} {done} {hours_remaining:.2f} mins remaining"
|
||||||
|
)
|
||||||
|
print(f"With SL lifetime {with_lifetime}")
|
@ -2,6 +2,7 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import arrow
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from app.events.event_dispatcher import EventDispatcher
|
from app.events.event_dispatcher import EventDispatcher
|
||||||
@ -30,6 +31,7 @@ step = 100
|
|||||||
updated = 0
|
updated = 0
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
with_premium = 0
|
with_premium = 0
|
||||||
|
with_lifetime = 0
|
||||||
for batch_start in range(pu_id_start, max_pu_id, step):
|
for batch_start in range(pu_id_start, max_pu_id, step):
|
||||||
partner_users = (
|
partner_users = (
|
||||||
Session.query(PartnerUser).filter(
|
Session.query(PartnerUser).filter(
|
||||||
@ -41,7 +43,10 @@ for batch_start in range(pu_id_start, max_pu_id, step):
|
|||||||
include_partner_subscription=False
|
include_partner_subscription=False
|
||||||
)
|
)
|
||||||
end_timestamp = None
|
end_timestamp = None
|
||||||
if subscription_end:
|
if partner_user.user.lifetime:
|
||||||
|
with_lifetime += 1
|
||||||
|
end_timestamp = arrow.get("2100-01-01").timestamp
|
||||||
|
elif subscription_end:
|
||||||
with_premium += 1
|
with_premium += 1
|
||||||
end_timestamp = subscription_end.timestamp
|
end_timestamp = subscription_end.timestamp
|
||||||
event = UserPlanChanged(plan_end_time=end_timestamp)
|
event = UserPlanChanged(plan_end_time=end_timestamp)
|
||||||
@ -60,4 +65,4 @@ for batch_start in range(pu_id_start, max_pu_id, step):
|
|||||||
print(
|
print(
|
||||||
f"\PartnerUser {batch_start}/{max_pu_id} {updated} {hours_remaining:.2f} mins remaining"
|
f"\PartnerUser {batch_start}/{max_pu_id} {updated} {hours_remaining:.2f} mins remaining"
|
||||||
)
|
)
|
||||||
print(f"With SL premium {with_premium}")
|
print(f"With SL premium {with_premium} lifetime {with_lifetime}")
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
<th scope="col">Verified</th>
|
<th scope="col">Verified</th>
|
||||||
<th scope="col">Status</th>
|
<th scope="col">Status</th>
|
||||||
<th scope="col">Paid</th>
|
<th scope="col">Paid</th>
|
||||||
|
<th scope="col">Premium</th>
|
||||||
<th>Subscription</th>
|
<th>Subscription</th>
|
||||||
<th>Created At</th>
|
<th>Created At</th>
|
||||||
<th>Updated At</th>
|
<th>Updated At</th>
|
||||||
@ -32,6 +33,7 @@
|
|||||||
<td class="text-success">Enabled</td>
|
<td class="text-success">Enabled</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>{{ "yes" if user.is_paid() else "No" }}</td>
|
<td>{{ "yes" if user.is_paid() else "No" }}</td>
|
||||||
|
<td>{{ "yes" if user.is_premium() else "No" }}</td>
|
||||||
<td>{{ user.get_active_subscription() }}</td>
|
<td>{{ user.get_active_subscription() }}</td>
|
||||||
<td>{{ user.created_at }}</td>
|
<td>{{ user.created_at }}</td>
|
||||||
<td>{{ user.updated_at }}</td>
|
<td>{{ user.updated_at }}</td>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user