BTHLABS-50: Safari Web extension
Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl> Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
@@ -1 +1,2 @@
|
||||
from .access_token import AccessToken # noqa: F401
|
||||
from .account import AccountAdmin # noqa: F401
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken
|
||||
|
||||
|
||||
class AccessTokenAdmin(admin.ModelAdmin):
|
||||
list_display = ('pk', 'account_uuid', 'origin', 'created_at', 'is_active')
|
||||
search_fields = ('pk', 'account_uuid', 'key', 'origin')
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
admin.site.register(AccessToken, AccessTokenAdmin)
|
||||
36
services/backend/hotpocket_backend/apps/accounts/backends.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import typing
|
||||
|
||||
from django.contrib.auth.backends import ModelBackend, UserModel
|
||||
from django.http import HttpRequest
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken, Account
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccessTokenBackend(ModelBackend):
|
||||
def authenticate(self,
|
||||
request: HttpRequest,
|
||||
access_token: AccessToken | None,
|
||||
) -> Account | None:
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
user = UserModel.objects.get(pk=access_token.account_uuid)
|
||||
except UserModel.DoesNotExist as exception:
|
||||
LOGGER.error(
|
||||
'Unhandled exception in AccessToken auth: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
|
||||
if self.user_can_authenticate(user) is False:
|
||||
return None
|
||||
|
||||
request.access_token = access_token
|
||||
return typing.cast(Account, user)
|
||||
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.http import HttpRequest
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken, Account
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccessTokenMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request: HttpRequest):
|
||||
if not hasattr(request, 'user'):
|
||||
raise ImproperlyConfigured('No `AuthenticationMiddleware`?')
|
||||
|
||||
authorization_header = request.headers.get('Authorization', None)
|
||||
if authorization_header is None:
|
||||
return
|
||||
|
||||
try:
|
||||
scheme, authorization = authorization_header.split(' ', maxsplit=1)
|
||||
assert scheme == 'Bearer', (
|
||||
f'Unsupported authorization scheme: `{scheme}`'
|
||||
)
|
||||
|
||||
access_token = AccessToken.active_objects.get(key=authorization)
|
||||
except (ValueError, AssertionError, AccessToken.DoesNotExist, Account.DoesNotExist) as exception:
|
||||
LOGGER.error(
|
||||
'Unhandled exception in AccessToken middleware: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
return
|
||||
|
||||
account = auth.authenticate(request, access_token=access_token)
|
||||
if account:
|
||||
request.user = account
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.3 on 2025-09-04 18:50
|
||||
|
||||
import uuid6
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0004_alter_account_settings_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AccessToken',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid6.uuid7, editable=False, primary_key=True, serialize=False)),
|
||||
('account_uuid', models.UUIDField(db_index=True, default=None)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('deleted_at', models.DateTimeField(blank=True, db_index=True, default=None, editable=False, null=True)),
|
||||
('key', models.CharField(db_index=True, default=None, editable=False, max_length=128, unique=True)),
|
||||
('origin', models.CharField(db_index=True, default=None)),
|
||||
('meta', models.JSONField(blank=True, default=dict, null=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Access Token',
|
||||
'verbose_name_plural': 'Access Tokens',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1 +1,2 @@
|
||||
from .access_token import AccessToken # noqa: F401
|
||||
from .account import Account # noqa: F401
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from hotpocket_backend.apps.core.models import Model
|
||||
|
||||
|
||||
class ActiveAccessTokensManager(models.Manager):
|
||||
def get_queryset(self) -> models.QuerySet[AccessToken]:
|
||||
return super().get_queryset().filter(
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
|
||||
class AccessToken(Model):
|
||||
key = models.CharField(
|
||||
blank=False,
|
||||
default=None,
|
||||
null=False,
|
||||
max_length=128,
|
||||
db_index=True,
|
||||
unique=True,
|
||||
editable=False,
|
||||
)
|
||||
origin = models.CharField(
|
||||
blank=False, default=None, null=False, db_index=True,
|
||||
)
|
||||
meta = models.JSONField(blank=True, default=dict, null=True)
|
||||
|
||||
objects = models.Manager()
|
||||
active_objects = ActiveAccessTokensManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Access Token')
|
||||
verbose_name_plural = _('Access Tokens')
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'<AccessToken pk={self.pk} account_uuid={self.account_uuid}>'
|
||||
@@ -0,0 +1 @@
|
||||
from .access_tokens import AccessTokensService # noqa: F401
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
import uuid6
|
||||
|
||||
from hotpocket_backend.apps.accounts.models import AccessToken
|
||||
from hotpocket_backend.apps.core.conf import settings
|
||||
from hotpocket_soa.dto.accounts import AccessTokensQuery
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccessTokensService:
|
||||
class AccessTokensServiceError(Exception):
|
||||
pass
|
||||
|
||||
class AccessTokenNotFound(AccessTokensServiceError):
|
||||
pass
|
||||
|
||||
def create(self,
|
||||
*,
|
||||
account_uuid: uuid.UUID,
|
||||
origin: str,
|
||||
meta: dict,
|
||||
) -> AccessToken:
|
||||
pk = uuid6.uuid7()
|
||||
key = hmac.new(
|
||||
settings.SECRET_KEY.encode('ascii'),
|
||||
msg=pk.bytes,
|
||||
digestmod=hashlib.sha256,
|
||||
)
|
||||
|
||||
return AccessToken.objects.create(
|
||||
pk=pk,
|
||||
account_uuid=account_uuid,
|
||||
key=key.hexdigest(),
|
||||
origin=origin,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
def get(self, *, pk: uuid.UUID) -> AccessToken:
|
||||
try:
|
||||
query_set = AccessToken.active_objects
|
||||
|
||||
return query_set.get(pk=pk)
|
||||
except AccessToken.DoesNotExist as exception:
|
||||
raise self.AccessTokenNotFound(
|
||||
f'Access Token not found: pk=`{pk}`',
|
||||
) from exception
|
||||
|
||||
def search(self,
|
||||
*,
|
||||
query: AccessTokensQuery,
|
||||
offset: int = 0,
|
||||
limit: int = 10,
|
||||
order_by: str = '-pk',
|
||||
) -> models.QuerySet[AccessToken]:
|
||||
filters = [
|
||||
models.Q(account_uuid=query.account_uuid),
|
||||
]
|
||||
|
||||
if query.before is not None:
|
||||
filters.append(models.Q(pk__lt=query.before))
|
||||
|
||||
result = AccessToken.active_objects.\
|
||||
filter(*filters).\
|
||||
order_by(order_by)
|
||||
|
||||
return result[offset:offset + limit]
|
||||
|
||||
def delete(self, *, pk: uuid.UUID) -> bool:
|
||||
access_token = self.get(pk=pk)
|
||||
access_token.soft_delete()
|
||||
|
||||
return True
|
||||
51
services/backend/hotpocket_backend/apps/core/rpc.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from bthlabs_jsonrpc_django import (
|
||||
DjangoExecutor,
|
||||
DjangoJSONRPCSerializer,
|
||||
JSONRPCView as BaseJSONRPCView,
|
||||
)
|
||||
from django.core.exceptions import ValidationError
|
||||
import uuid6
|
||||
|
||||
|
||||
class JSONRPCSerializer(DjangoJSONRPCSerializer):
|
||||
STRING_COERCIBLE_TYPES: typing.Any = (
|
||||
*DjangoJSONRPCSerializer.STRING_COERCIBLE_TYPES,
|
||||
uuid6.UUID,
|
||||
)
|
||||
|
||||
def serialize_value(self, value: typing.Any) -> typing.Any:
|
||||
if isinstance(value, ValidationError):
|
||||
result: typing.Any = None
|
||||
|
||||
if hasattr(value, 'error_dict') is True:
|
||||
result = {}
|
||||
for field, errors in value.error_dict.items():
|
||||
result[field] = [
|
||||
error.code
|
||||
for error
|
||||
in errors
|
||||
]
|
||||
elif hasattr(value, 'error_list') is True:
|
||||
result = [
|
||||
error.code
|
||||
for error in value.error_list
|
||||
]
|
||||
else:
|
||||
result = value.code
|
||||
|
||||
return self.serialize_value(result)
|
||||
|
||||
return super().serialize_value(value)
|
||||
|
||||
|
||||
class Executor(DjangoExecutor):
|
||||
serializer = JSONRPCSerializer
|
||||
|
||||
|
||||
class JSONRPCView(BaseJSONRPCView):
|
||||
executor = Executor
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class MessageLevelAlertClass(enum.Enum):
|
||||
debug = 'alert-secondary'
|
||||
@@ -15,3 +17,7 @@ class MessageLevelAlertClass(enum.Enum):
|
||||
class StarUnstarAssociationViewMode(enum.Enum):
|
||||
STAR = 'STAR'
|
||||
UNSTAR = 'UNSTAR'
|
||||
|
||||
|
||||
class UIAccessTokenOriginApp(enum.Enum):
|
||||
SAFARI_WEB_EXTENSION = _('Safari Web Extension')
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from .base import BrowseParams as BaseBrowseParams
|
||||
|
||||
|
||||
class AppsBrowseParams(BaseBrowseParams):
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from crispy_forms.layout import Submit
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from hotpocket_backend.apps.ui.forms.base import ConfirmationMixin, Form
|
||||
|
||||
|
||||
class AppForm(Form):
|
||||
pass
|
||||
|
||||
|
||||
class ConfirmationForm(ConfirmationMixin, AppForm):
|
||||
origin_app = forms.CharField(
|
||||
label=_('App'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
platform = forms.CharField(
|
||||
label=_('Platform'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
version = forms.CharField(
|
||||
label=_('Version'),
|
||||
required=False,
|
||||
disabled=True,
|
||||
show_hidden_initial=True,
|
||||
)
|
||||
|
||||
def get_layout_fields(self) -> list[str]:
|
||||
return [
|
||||
'canhazconfirm',
|
||||
'origin_app',
|
||||
'platform',
|
||||
'version',
|
||||
]
|
||||
|
||||
|
||||
class DeleteForm(ConfirmationForm):
|
||||
def get_submit_button(self) -> Submit:
|
||||
return Submit('submit', _('Delete'), css_class='btn btn-danger')
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit
|
||||
from django.contrib.auth.forms import (
|
||||
AuthenticationForm as BaseAuthenticationForm,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class LoginForm(BaseAuthenticationForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper(self)
|
||||
self.helper.attrs = {
|
||||
'id': self.__class__.__name__,
|
||||
'novalidate': '',
|
||||
}
|
||||
self.helper.layout = Layout(
|
||||
'username',
|
||||
'password',
|
||||
FormActions(
|
||||
Submit('submit', _('Log in'), css_class='btn btn-primary'),
|
||||
template='ui/ui/forms/formactions.html',
|
||||
css_class='mb-0',
|
||||
),
|
||||
)
|
||||
@@ -6,32 +6,11 @@ from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import (
|
||||
AuthenticationForm as BaseAuthenticationForm,
|
||||
PasswordChangeForm as BasePasswordChangeForm,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import Form
|
||||
|
||||
|
||||
class LoginForm(BaseAuthenticationForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper(self)
|
||||
self.helper.attrs = {
|
||||
'id': self.__class__.__name__,
|
||||
'novalidate': '',
|
||||
}
|
||||
self.helper.layout = Layout(
|
||||
'username',
|
||||
'password',
|
||||
FormActions(
|
||||
Submit('submit', _('Log in'), css_class='btn btn-primary'),
|
||||
template='ui/ui/forms/formactions.html',
|
||||
css_class='mb-0',
|
||||
),
|
||||
)
|
||||
from hotpocket_backend.apps.ui.forms.base import Form
|
||||
|
||||
|
||||
class ProfileForm(Form):
|
||||
@@ -131,17 +110,17 @@ class PasswordForm(BasePasswordChangeForm):
|
||||
|
||||
|
||||
class FederatedPasswordForm(PasswordForm):
|
||||
current_password = forms.CharField(
|
||||
old_password = forms.CharField(
|
||||
label=_('Old password'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
new_password = forms.CharField(
|
||||
new_password1 = forms.CharField(
|
||||
label=_('New password'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
)
|
||||
new_password_again = forms.CharField(
|
||||
new_password2 = forms.CharField(
|
||||
label=_('New password confirmation'),
|
||||
disabled=True,
|
||||
required=False,
|
||||
@@ -5,19 +5,14 @@ from crispy_forms.layout import Submit
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base import Form
|
||||
from .base import ConfirmationMixin, Form
|
||||
|
||||
|
||||
class AssociationForm(Form):
|
||||
pass
|
||||
|
||||
|
||||
class ConfirmationForm(AssociationForm):
|
||||
canhazconfirm = forms.CharField(
|
||||
label='',
|
||||
required=True,
|
||||
widget=forms.HiddenInput,
|
||||
)
|
||||
class ConfirmationForm(ConfirmationMixin, AssociationForm):
|
||||
title = forms.CharField(
|
||||
label=_('Title'),
|
||||
required=False,
|
||||
|
||||
@@ -61,3 +61,11 @@ class Form(forms.Form):
|
||||
template=self.get_form_actions_template(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ConfirmationMixin(forms.Form):
|
||||
canhazconfirm = forms.CharField(
|
||||
label='',
|
||||
required=True,
|
||||
widget=forms.HiddenInput,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import accounts # noqa: F401
|
||||
from . import saves # noqa: F401
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import access_tokens # noqa: F401
|
||||
from . import auth # noqa: F401
|
||||
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from bthlabs_jsonrpc_core import register_method
|
||||
from django import db
|
||||
from django.http import HttpRequest
|
||||
|
||||
from hotpocket_soa.services import AccessTokensService
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@register_method('accounts.access_tokens.create')
|
||||
def create(request: HttpRequest,
|
||||
auth_key: str,
|
||||
meta: dict,
|
||||
) -> str:
|
||||
with db.transaction.atomic():
|
||||
try:
|
||||
assert 'extension_auth_key' in request.session, 'Auth key missing'
|
||||
assert request.session['extension_auth_key'] == auth_key, (
|
||||
'Auth key mismatch'
|
||||
)
|
||||
except AssertionError as exception:
|
||||
LOGGER.error(
|
||||
'Unable to issue access token: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
raise
|
||||
|
||||
access_token = AccessTokensService().create(
|
||||
account_uuid=request.user.pk,
|
||||
origin=request.META['HTTP_ORIGIN'],
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
request.session.pop('extension_auth_key')
|
||||
request.session.save()
|
||||
|
||||
return access_token.key
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from bthlabs_jsonrpc_core import register_method
|
||||
from django.http import HttpRequest
|
||||
|
||||
|
||||
@register_method('accounts.auth.check')
|
||||
def check(request: HttpRequest) -> bool:
|
||||
return request.user.is_anonymous is False
|
||||
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from bthlabs_jsonrpc_core import register_method
|
||||
from django import db
|
||||
from django.http import HttpRequest
|
||||
|
||||
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
|
||||
from hotpocket_soa.dto.associations import AssociationOut
|
||||
|
||||
|
||||
@register_method(method='saves.create')
|
||||
def create(request: HttpRequest, url: str) -> AssociationOut:
|
||||
with db.transaction.atomic():
|
||||
association = CreateSaveWorkflow().run_rpc(
|
||||
request=request,
|
||||
account=request.user,
|
||||
url=url,
|
||||
)
|
||||
|
||||
return association
|
||||
@@ -1,3 +1,4 @@
|
||||
from .access_tokens import UIAccessTokensService # noqa: F401
|
||||
from .associations import UIAssociationsService # noqa: F401
|
||||
from .imports import UIImportsService # noqa: F401
|
||||
from .saves import UISavesService # noqa: F401
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404
|
||||
|
||||
from hotpocket_soa.dto.accounts import AccessTokenOut
|
||||
from hotpocket_soa.services import AccessTokensService
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UIAccessTokensService:
|
||||
def __init__(self):
|
||||
self.access_tokens_service = AccessTokensService()
|
||||
|
||||
def get_or_404(self,
|
||||
*,
|
||||
account_uuid: uuid.UUID,
|
||||
pk: uuid.UUID,
|
||||
) -> AccessTokenOut:
|
||||
try:
|
||||
return AccessTokensService().get(
|
||||
account_uuid=account_uuid,
|
||||
pk=pk,
|
||||
)
|
||||
except AccessTokensService.AccessTokenNotFound as exception:
|
||||
LOGGER.error(
|
||||
'Access Token not found: account_uuid=`%s` pk=`%s`',
|
||||
account_uuid,
|
||||
pk,
|
||||
exc_info=exception,
|
||||
)
|
||||
raise Http404()
|
||||
except AccessTokensService.AccessTokenAccessDenied as exception:
|
||||
LOGGER.error(
|
||||
'Access Token access denied: account_uuid=`%s` pk=`%s`',
|
||||
account_uuid,
|
||||
pk,
|
||||
exc_info=exception,
|
||||
)
|
||||
raise PermissionDenied()
|
||||
@@ -1,7 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from bthlabs_jsonrpc_core import JSONRPCInternalError
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
import django.db
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
@@ -9,19 +11,19 @@ from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from hotpocket_backend.apps.accounts.types import PAccount
|
||||
from hotpocket_soa.dto.saves import SaveIn
|
||||
from hotpocket_soa.dto.associations import AssociationOut
|
||||
from hotpocket_soa.dto.celery import AsyncResultOut
|
||||
from hotpocket_soa.dto.saves import SaveIn, SaveOut
|
||||
from hotpocket_soa.services import SavesService
|
||||
|
||||
from .base import SaveWorkflow
|
||||
|
||||
|
||||
class CreateSaveWorkflow(SaveWorkflow):
|
||||
def run(self,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
account: PAccount,
|
||||
url: str,
|
||||
force_post_save: bool = False,
|
||||
) -> HttpResponse:
|
||||
def create_associate_and_process(self,
|
||||
account: PAccount,
|
||||
url: str,
|
||||
) -> tuple[SaveOut, AssociationOut, AsyncResultOut | None]:
|
||||
with django.db.transaction.atomic():
|
||||
save = self.create(
|
||||
account.pk,
|
||||
@@ -30,6 +32,23 @@ class CreateSaveWorkflow(SaveWorkflow):
|
||||
|
||||
association = self.associate(account.pk, save)
|
||||
|
||||
processing_result: AsyncResultOut | None = None
|
||||
if save.last_processed_at is None:
|
||||
processing_result = self.schedule_processing(save)
|
||||
|
||||
return (save, association, processing_result)
|
||||
|
||||
def run(self,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
account: PAccount,
|
||||
url: str,
|
||||
force_post_save: bool = False,
|
||||
) -> HttpResponse:
|
||||
save, association, processing_result = self.create_associate_and_process(
|
||||
account, url,
|
||||
)
|
||||
|
||||
response = redirect(reverse('ui.associations.browse'))
|
||||
if force_post_save is True or save.is_netloc_banned is True:
|
||||
response = redirect(reverse(
|
||||
@@ -46,7 +65,22 @@ class CreateSaveWorkflow(SaveWorkflow):
|
||||
response.headers['X-HotPocket-Testing-Save-PK'] = save.pk
|
||||
response.headers['X-HotPocket-Testing-Association-PK'] = association.pk
|
||||
|
||||
if save.last_processed_at is None:
|
||||
processing_result = self.schedule_processing(save) # noqa: F841
|
||||
|
||||
return response
|
||||
|
||||
def run_rpc(self,
|
||||
*,
|
||||
request: HttpRequest,
|
||||
account: PAccount,
|
||||
url: str,
|
||||
) -> AssociationOut:
|
||||
try:
|
||||
save, association, processing_result = self.create_associate_and_process(
|
||||
account, url,
|
||||
)
|
||||
|
||||
return association
|
||||
except SavesService.SavesServiceError as exception:
|
||||
if isinstance(exception.__cause__, ValidationError) is True:
|
||||
raise JSONRPCInternalError(data=exception.__cause__)
|
||||
|
||||
raise
|
||||
|
||||
@@ -36,7 +36,7 @@ body:not(.ui-js-enabled) .ui-noscript-hide {
|
||||
}
|
||||
|
||||
#navbar .ui-navbar-brand > img {
|
||||
border-radius: 0.25rem;
|
||||
border-radius: 20%;
|
||||
height: 1.5rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
@@ -45,11 +45,11 @@ body:not(.ui-js-enabled) .ui-noscript-hide {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ui-save-card .card-footer .spinner-border {
|
||||
.spinner-border.ui-htmx-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-save-card .card-footer .spinner-border.htmx-request {
|
||||
.spinner-border.ui-htmx-indicator.htmx-request {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 874 B |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,63 @@
|
||||
/*!
|
||||
* HotPocket by BTHLabs (https://hotpocket.app/)
|
||||
* Copyright 2025-present BTHLabs <contact@bthlabs.pl> (https://bthlabs.pl/)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
((HotPocket, htmx) => {
|
||||
class BrowseAccountAppsView {
|
||||
constructor (app) {
|
||||
this.app = app;
|
||||
}
|
||||
onLoad = (event) => {
|
||||
document.addEventListener(
|
||||
'HotPocket:BrowseAccountAppsView:updateLoadMoreButton',
|
||||
this.onUpdateLoadMoreButton,
|
||||
);
|
||||
|
||||
document.addEventListener(
|
||||
'HotPocket:BrowseAccountAppsView:delete',
|
||||
this.onDelete,
|
||||
);
|
||||
};
|
||||
onUpdateLoadMoreButton = (event) => {
|
||||
const button = document.querySelector('#BrowseAccountAppsView .ui-load-more-button');
|
||||
if (button) {
|
||||
if (event.detail.next_url) {
|
||||
button.setAttribute('hx-get', event.detail.next_url);
|
||||
button.classList.remove('disable');
|
||||
button.removeAttribute('disabled', true);
|
||||
} else {
|
||||
button.classList.add('disable');
|
||||
button.setAttribute('disabled', true);
|
||||
}
|
||||
|
||||
htmx.process('#BrowseAccountAppsView .ui-load-more-button');
|
||||
}
|
||||
};
|
||||
onDelete = (event) => {
|
||||
if (event.detail && event.detail.pk) {
|
||||
const elementsToRemove = document.querySelectorAll(
|
||||
`[data-access-token="${event.detail.pk}"]`,
|
||||
);
|
||||
for (let elementToRemove of elementsToRemove) {
|
||||
elementToRemove.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
HotPocket.addPlugin('UI.BrowseAccountAppsView', (app) => {
|
||||
return new BrowseAccountAppsView(app);
|
||||
});
|
||||
})(window.HotPocket, window.htmx);
|
||||
@@ -20,21 +20,25 @@
|
||||
this.app = app;
|
||||
}
|
||||
onLoad = (event) => {
|
||||
document.addEventListener('HotPocket:BrowseSavesView:updateLoadMoreButton', (event) => {
|
||||
const button = document.querySelector('#BrowseSavesView .ui-load-more-button');
|
||||
if (button) {
|
||||
if (event.detail.next_url) {
|
||||
button.setAttribute('hx-get', event.detail.next_url);
|
||||
button.classList.remove('disable');
|
||||
button.removeAttribute('disabled', true);
|
||||
} else {
|
||||
button.classList.add('disable');
|
||||
button.setAttribute('disabled', true);
|
||||
}
|
||||
|
||||
htmx.process('#BrowseSavesView .ui-load-more-button');
|
||||
document.addEventListener(
|
||||
'HotPocket:BrowseSavesView:updateLoadMoreButton',
|
||||
this.onUpdateLoadMoreButton,
|
||||
);
|
||||
};
|
||||
onUpdateLoadMoreButton = (event) => {
|
||||
const button = document.querySelector('#BrowseSavesView .ui-load-more-button');
|
||||
if (button) {
|
||||
if (event.detail.next_url) {
|
||||
button.setAttribute('hx-get', event.detail.next_url);
|
||||
button.classList.remove('disable');
|
||||
button.removeAttribute('disabled', true);
|
||||
} else {
|
||||
button.classList.add('disable');
|
||||
button.setAttribute('disabled', true);
|
||||
}
|
||||
});
|
||||
|
||||
htmx.process('#BrowseSavesView .ui-load-more-button');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Apps' %} | {% translate 'Account' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div id="BrowseAccountAppsView" class="container">
|
||||
<p class="display-6 my-3">{% translate 'Apps' %}</p>
|
||||
|
||||
{% include 'ui/accounts/partials/nav.html' with active_tab='apps' %}
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{% translate 'App' %}</td>
|
||||
<td>{% translate 'Platform' %}</td>
|
||||
<td>{% translate 'Version' %}</td>
|
||||
<td>{% translate 'Authorized at' %}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="ui-account-apps">
|
||||
{% include 'ui/accounts/partials/apps/apps.html' with access_tokens=access_tokens params=params %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="my-3 text-center {% if not access_tokens and params.before is None %}d-none{% endif %}">
|
||||
<button
|
||||
class="btn btn-primary {% if not before %}disabled{% endif %} ui-noscript-hide ui-load-more-button"
|
||||
hx-get="{{ next_url }}"
|
||||
hx-push-url="true"
|
||||
hx-swap="beforeend"
|
||||
hx-target="#BrowseAccountAppsView .ui-account-apps"
|
||||
>
|
||||
{% translate 'Load more' %}
|
||||
</button>
|
||||
|
||||
<a
|
||||
class="btn btn-primary {% if not before %}disabled{% endif %} ui-noscript-show"
|
||||
href="{{ next_url }}"
|
||||
>
|
||||
{% translate 'Load more' %}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<template id="BrowseAccountAppsView-DeleteModal">
|
||||
<div class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{% translate 'Delete an app authorization?' %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Close' %}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% include 'ui/accounts/partials/apps/delete_confirmation.html' with alert_class="mb-0" %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate 'Cancel' %}</button>
|
||||
<button type="button" class="btn btn-danger" data-ui-modal-action="confirm">
|
||||
{% translate 'Delete' %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load crispy_forms_tags i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Delete an app authorization?' %} | {% translate 'Account' %}{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div id="BrowseAccountAppsView" class="container">
|
||||
<p class="display-6 my-3">{% translate 'Delete an app authorization?' %}</p>
|
||||
|
||||
{% include 'ui/accounts/partials/nav.html' with active_tab='apps' %}
|
||||
|
||||
{% include 'ui/accounts/partials/apps/delete_confirmation.html' %}
|
||||
|
||||
{% crispy form %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% for access_token in access_tokens %}
|
||||
<tr data-access-token="{{ access_token.pk }}">
|
||||
<td>{{ access_token|render_access_token_app }}</td>
|
||||
<td>{{ access_token|render_access_token_platform }}</td>
|
||||
<td><code>{{ access_token.meta.version }}</code></td>
|
||||
<td>{{ access_token.created_at }}</td>
|
||||
</tr>
|
||||
<tr data-access-token="{{ access_token.pk }}">
|
||||
<td colspan="4">
|
||||
<div class="d-flex justify-content-end align-items-center">
|
||||
<div class="spinner-border spinner-border-sm ui-htmx-indicator" role="status">
|
||||
<span class="visually-hidden">{% translate 'Processing' %}</span>
|
||||
</div>
|
||||
<a
|
||||
class="btn btn-sm btn-danger ms-2"
|
||||
data-ui-modal="#BrowseAccountAppsView-DeleteModal"
|
||||
hx-confirm='CANHAZCONFIRM'
|
||||
hx-indicator='[data-access-token="{{ access_token.pk }}"] .ui-htmx-indicator'
|
||||
hx-post="{% url 'ui.accounts.apps.delete' pk=access_token.pk %}"
|
||||
hx-swap="delete"
|
||||
hx-target='[data-access-token="{{ access_token.pk }}"]'
|
||||
hx-vars='{"canhazconfirm":true}'
|
||||
href="{% url 'ui.accounts.apps.delete' pk=access_token.pk %}"
|
||||
>
|
||||
<i class="bi bi-trash3-fill"></i> {% translate 'Delete' %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
{% if not HTMX %}
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
{% if params.before is None %}
|
||||
<strong>{% translate "You haven't authorized any apps yet." %}</strong>
|
||||
{% else %}
|
||||
<span>{% translate "You've reached the end of the line." %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% load i18n static ui %}
|
||||
|
||||
<div class="alert alert-danger {{ alert_class|default_if_none:'' }}">
|
||||
<h4 class="alert-heading">{% translate 'Point of no return' %}</h4>
|
||||
<p class="lead mb-0">{% translate 'Are you sure you want to delete this app authorization?' %}</p>
|
||||
<p class="mb-0"><strong>You'll need to authorize again if you ever change your mind.</strong></p>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@
|
||||
{% if active_tab == 'profile' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" aria-current="page" href="{% url 'ui.accounts.settings.profile' %}">
|
||||
<a class="nav-link" href="{% url 'ui.accounts.settings.profile' %}">
|
||||
{% endif %}
|
||||
{% translate 'Profile' %}
|
||||
</a>
|
||||
@@ -14,7 +14,7 @@
|
||||
{% if active_tab == 'password' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" aria-current="page" href="{% url 'ui.accounts.settings.password' %}">
|
||||
<a class="nav-link" href="{% url 'ui.accounts.settings.password' %}">
|
||||
{% endif %}
|
||||
{% translate 'Password' %}
|
||||
</a>
|
||||
@@ -23,9 +23,18 @@
|
||||
{% if active_tab == 'settings' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" aria-current="page" href="{% url 'ui.accounts.settings.settings' %}">
|
||||
<a class="nav-link" href="{% url 'ui.accounts.settings.settings' %}">
|
||||
{% endif %}
|
||||
{% translate 'Settings' %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% if active_tab == 'apps' %}
|
||||
<a class="nav-link active" aria-current="page" href="#">
|
||||
{% else %}
|
||||
<a class="nav-link" href="{% url 'ui.accounts.apps.index' %}">
|
||||
{% endif %}
|
||||
{% translate 'Apps' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<a href="{{ association.target.url }}" target="_blank" rel="noopener noreferer"><small>{{ association.target.url|render_url_domain }}</small></a>
|
||||
<div class="ms-auto flex-shrink-0 d-flex align-items-center">
|
||||
{% if not association.archived_at %}
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<div class="spinner-border spinner-border-sm ui-htmx-indicator" role="status">
|
||||
<span class="visually-hidden">{% translate 'Processing' %}</span>
|
||||
</div>
|
||||
{% if association.is_starred %}
|
||||
@@ -35,7 +35,7 @@
|
||||
<a
|
||||
class="dropdown-item"
|
||||
hx-get="{% url 'ui.associations.unstar' pk=association.pk %}"
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .spinner-border'
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .ui-htmx-indicator'
|
||||
hx-swap="innerHTML"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
href="{% url 'ui.associations.unstar' pk=association.pk %}"
|
||||
@@ -48,7 +48,7 @@
|
||||
<a
|
||||
class="dropdown-item"
|
||||
hx-get="{% url 'ui.associations.star' pk=association.pk %}"
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .spinner-border'
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .ui-htmx-indicator'
|
||||
hx-swap="innerHTML"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
href="{% url 'ui.associations.star' pk=association.pk %}"
|
||||
@@ -70,7 +70,7 @@
|
||||
class="dropdown-item text-warning"
|
||||
data-ui-modal="#BrowseSavesView-RefreshModal"
|
||||
hx-confirm='CANHAZCONFIRM'
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .spinner-border'
|
||||
hx-indicator='[data-association="{{ association.pk }}"] .ui-htmx-indicator'
|
||||
hx-post="{% url 'ui.associations.refresh' pk=association.pk %}"
|
||||
hx-swap="none"
|
||||
hx-target='[data-association="{{ association.pk }}"]'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "ui/page.html" %}
|
||||
|
||||
{% load i18n static ui %}
|
||||
|
||||
{% block title %}{% translate 'Redirecting back to the extension...' %}{% endblock %}
|
||||
|
||||
{% block button_bar_class %}d-none{% endblock %}
|
||||
|
||||
{% block page_body %}
|
||||
<div class="container">
|
||||
<div class="alert alert-success mt-3" role="alert">
|
||||
<h4 class="alert-heading">{% translate 'Done!' %}</h4>
|
||||
<p class="lead mb-0">
|
||||
{% translate "You've successfully logged in to the extension." %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -10,7 +10,7 @@
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle ui-navbar-brand" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{% spaceless %}
|
||||
<img src="{% static 'ui/img/icon-180.png' %}">
|
||||
<img src="{% static 'ui/img/icon-48.png' %}">
|
||||
<span class="ms-2">{% block top_nav_title %}HotPocket{% endblock %}</span>
|
||||
{% endspaceless %}
|
||||
</a>
|
||||
@@ -35,7 +35,7 @@
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link pe-none ui-navbar-brand">
|
||||
<img src="{% static 'ui/img/icon-180.png' %}">
|
||||
<img src="{% static 'ui/img/icon-48.png' %}">
|
||||
<span class="ms-2">{{ SITE_TITLE }}</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -146,6 +146,7 @@
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.Modal.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.BrowseSavesView.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.ViewAssociationView.js' %}" type="text/javascript"></script>
|
||||
<script src="{% static 'ui/js/hotpocket-backend.ui.BrowseAccountAppsView.js' %}" type="text/javascript"></script>
|
||||
{% block page_scripts %}{% endblock %}
|
||||
<script type="text/javascript">
|
||||
(() => {
|
||||
|
||||
@@ -8,10 +8,19 @@ import urllib.parse
|
||||
from django import template
|
||||
from django.contrib.messages import Message
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from hotpocket_backend.apps.ui.constants import MessageLevelAlertClass
|
||||
from hotpocket_backend.apps.ui.constants import (
|
||||
MessageLevelAlertClass,
|
||||
UIAccessTokenOriginApp,
|
||||
)
|
||||
from hotpocket_backend.apps.ui.dto.base import BrowseParams
|
||||
from hotpocket_common.constants import AssociationsSearchMode
|
||||
from hotpocket_common.constants import (
|
||||
AccessTokenOriginApp,
|
||||
AssociationsSearchMode,
|
||||
)
|
||||
from hotpocket_soa.dto.accounts import AccessTokenOut
|
||||
from hotpocket_soa.dto.saves import SaveOut
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
@@ -115,3 +124,49 @@ def alert_class(message: Message | None) -> str:
|
||||
)
|
||||
|
||||
return 'alert-secondary'
|
||||
|
||||
|
||||
@register.filter(name='render_access_token_app')
|
||||
def render_access_token_app(access_token: AccessTokenOut) -> str:
|
||||
app: str = access_token.get_origin_app_id()
|
||||
variant = 'secondary'
|
||||
|
||||
origin_app = access_token.get_origin_app()
|
||||
match origin_app:
|
||||
case AccessTokenOriginApp.SAFARI_WEB_EXTENSION:
|
||||
app = UIAccessTokenOriginApp[origin_app.value].value
|
||||
variant = 'info'
|
||||
|
||||
return format_html(
|
||||
'<span class="badge text-bg-{}">{}</span>',
|
||||
variant,
|
||||
app,
|
||||
)
|
||||
|
||||
|
||||
@register.filter(name='render_access_token_platform')
|
||||
def render_access_token_platform(access_token: AccessTokenOut) -> str:
|
||||
match access_token.meta.get('platform', None):
|
||||
case 'MacIntel':
|
||||
return 'macOS'
|
||||
|
||||
case 'iPhone':
|
||||
return 'iOS'
|
||||
|
||||
case 'iPad':
|
||||
return 'iPadOS'
|
||||
|
||||
case 'Win32':
|
||||
return 'Windows'
|
||||
|
||||
case 'Linux x86_64':
|
||||
return 'Linux'
|
||||
|
||||
case 'Linux armv81':
|
||||
return 'Linux'
|
||||
|
||||
case None:
|
||||
return _('Unknown')
|
||||
|
||||
case _:
|
||||
return access_token.meta['platform']
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from bthlabs_jsonrpc_django import is_authenticated
|
||||
from django.urls import path
|
||||
|
||||
from hotpocket_backend.apps.core.rpc import JSONRPCView
|
||||
from hotpocket_backend.apps.ui.constants import StarUnstarAssociationViewMode
|
||||
|
||||
# isort: off
|
||||
@@ -20,33 +22,44 @@ from .views import (
|
||||
urlpatterns = [
|
||||
path(
|
||||
'accounts/login/',
|
||||
accounts.LoginView.as_view(),
|
||||
accounts.auth.LoginView.as_view(),
|
||||
name='ui.accounts.login',
|
||||
),
|
||||
path(
|
||||
'accounts/post-login/',
|
||||
accounts.PostLoginView.as_view(),
|
||||
accounts.auth.PostLoginView.as_view(),
|
||||
name='ui.accounts.post_login',
|
||||
),
|
||||
path('accounts/logout/', accounts.logout, name='ui.accounts.logout'),
|
||||
path('accounts/browse/', accounts.browse, name='ui.accounts.browse'),
|
||||
path('accounts/settings/', accounts.settings, name='ui.accounts.settings'),
|
||||
path('accounts/logout/', accounts.auth.logout, name='ui.accounts.logout'),
|
||||
path('accounts/browse/', accounts.browse.browse, name='ui.accounts.browse'),
|
||||
path('accounts/settings/', accounts.settings.settings, name='ui.accounts.settings'),
|
||||
path(
|
||||
'accounts/settings/profile/',
|
||||
accounts.ProfileView.as_view(),
|
||||
accounts.settings.ProfileView.as_view(),
|
||||
name='ui.accounts.settings.profile',
|
||||
),
|
||||
path(
|
||||
'accounts/settings/password/',
|
||||
accounts.PasswordView.as_view(),
|
||||
accounts.settings.PasswordView.as_view(),
|
||||
name='ui.accounts.settings.password',
|
||||
),
|
||||
path(
|
||||
'accounts/settings/settings/',
|
||||
accounts.SettingsView.as_view(),
|
||||
accounts.settings.SettingsView.as_view(),
|
||||
name='ui.accounts.settings.settings',
|
||||
),
|
||||
path('accounts/', accounts.index, name='ui.accounts.index'),
|
||||
path('accounts/apps/', accounts.apps.index, name='ui.accounts.apps.index'),
|
||||
path(
|
||||
'accounts/apps/browse/',
|
||||
accounts.apps.browse,
|
||||
name='ui.accounts.apps.browse',
|
||||
),
|
||||
path(
|
||||
'accounts/apps/delete/<str:pk>',
|
||||
accounts.apps.DeleteView.as_view(),
|
||||
name='ui.accounts.apps.delete',
|
||||
),
|
||||
path('accounts/', accounts.index.index, name='ui.accounts.index'),
|
||||
path(
|
||||
'imports/pocket/',
|
||||
imports.PocketImportView.as_view(),
|
||||
@@ -62,6 +75,16 @@ urlpatterns = [
|
||||
integrations.android.share_sheet,
|
||||
name='ui.integrations.android.share_sheet',
|
||||
),
|
||||
path(
|
||||
'integrations/extension/authenticate/',
|
||||
integrations.extension.authenticate,
|
||||
name='ui.integrations.extension.authenticate',
|
||||
),
|
||||
path(
|
||||
'integrations/extension/post-authenticate/',
|
||||
integrations.extension.post_authenticate,
|
||||
name='ui.integrations.extension.post_authenticate',
|
||||
),
|
||||
path(
|
||||
'saves/create/',
|
||||
saves.CreateView.as_view(),
|
||||
@@ -107,5 +130,12 @@ urlpatterns = [
|
||||
),
|
||||
path('associations/', associations.index, name='ui.associations.index'),
|
||||
path('manifest.json', meta.manifest_json, name='ui.meta.manifest_json'),
|
||||
path(
|
||||
'rpc/',
|
||||
JSONRPCView.as_view(
|
||||
auth_checks=[is_authenticated],
|
||||
),
|
||||
name='ui.rpc',
|
||||
),
|
||||
path('', index.index, name='ui.index.index'),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from . import apps # noqaz: F401
|
||||
from . import auth # noqa: F401
|
||||
from . import browse # noqa: F401
|
||||
from . import index # noqa: F401
|
||||
from . import settings # noqa: F401
|
||||
@@ -0,0 +1,167 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from django.contrib import messages
|
||||
import django.db
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView
|
||||
from django_htmx.http import trigger_client_event
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
|
||||
from hotpocket_backend.apps.htmx import messages as htmx_messages
|
||||
from hotpocket_backend.apps.ui.constants import UIAccessTokenOriginApp
|
||||
from hotpocket_backend.apps.ui.dto.accounts import AppsBrowseParams
|
||||
from hotpocket_backend.apps.ui.forms.accounts.apps import DeleteForm
|
||||
from hotpocket_backend.apps.ui.services import UIAccessTokensService
|
||||
from hotpocket_soa.dto.accounts import AccessTokenOut, AccessTokensQuery
|
||||
from hotpocket_soa.services import AccessTokensService
|
||||
|
||||
|
||||
class AccessTokenMixin:
|
||||
def get_access_token(self) -> AccessTokenOut:
|
||||
if hasattr(self, '_access_token') is False:
|
||||
setattr(
|
||||
self,
|
||||
'_access_token',
|
||||
UIAccessTokensService().get_or_404(
|
||||
account_uuid=self.request.user.pk, # type: ignore[attr-defined]
|
||||
pk=self.kwargs['pk'], # type: ignore[attr-defined]
|
||||
),
|
||||
)
|
||||
|
||||
return self._access_token # type: ignore[attr-defined]
|
||||
|
||||
|
||||
class DetailView(AccessTokenMixin, AccountRequiredMixin, FormView):
|
||||
def get_context_data(self, **kwargs) -> dict:
|
||||
result = super().get_context_data(**kwargs)
|
||||
|
||||
result.update({
|
||||
'access_token': self.get_access_token(),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class ConfirmationView(DetailView):
|
||||
def get_initial(self) -> dict:
|
||||
result = super().get_initial()
|
||||
|
||||
access_token: AccessTokenOut = self.get_access_token()
|
||||
|
||||
origin_app = access_token.get_origin_app()
|
||||
if origin_app is not None:
|
||||
origin_app = UIAccessTokenOriginApp[origin_app.value].value
|
||||
|
||||
result.update({
|
||||
'canhazconfirm': 'hai',
|
||||
'origin_app': origin_app or access_token.get_origin_app_id(),
|
||||
'platform': access_token.meta.get('platform', '-'),
|
||||
'version': access_token.meta.get('version', '-'),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.apps.browse')
|
||||
|
||||
|
||||
@account_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.apps.browse'))
|
||||
|
||||
|
||||
@account_required
|
||||
def browse(request: HttpRequest) -> HttpResponse:
|
||||
params = AppsBrowseParams.from_request(request=request)
|
||||
|
||||
access_tokens = AccessTokensService().search(
|
||||
query=AccessTokensQuery.model_validate(dict(
|
||||
account_uuid=request.user.pk,
|
||||
before=params.before,
|
||||
)),
|
||||
limit=params.limit,
|
||||
)
|
||||
|
||||
before: uuid.UUID | None = None
|
||||
if len(access_tokens) == params.limit:
|
||||
before = access_tokens[-1].pk
|
||||
|
||||
next_url: str | None = None
|
||||
if before is not None:
|
||||
next_url = reverse('ui.accounts.apps.browse', query=[
|
||||
('before', before),
|
||||
('limit', params.limit),
|
||||
])
|
||||
|
||||
context = {
|
||||
'access_tokens': access_tokens,
|
||||
'params': params,
|
||||
'before': before,
|
||||
'next_url': next_url,
|
||||
}
|
||||
|
||||
if request.htmx:
|
||||
response = render(
|
||||
request,
|
||||
'ui/accounts/partials/apps/apps.html',
|
||||
context,
|
||||
)
|
||||
|
||||
return trigger_client_event(
|
||||
response,
|
||||
'HotPocket:BrowseAccountAppsView:updateLoadMoreButton',
|
||||
{'next_url': next_url},
|
||||
after='swap',
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
'ui/accounts/apps/browse.html',
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
class DeleteView(ConfirmationView):
|
||||
template_name = 'ui/accounts/apps/delete.html'
|
||||
form_class = DeleteForm
|
||||
|
||||
def form_valid(self, form: DeleteForm) -> HttpResponse:
|
||||
with django.db.transaction.atomic():
|
||||
result = AccessTokensService().delete(
|
||||
access_token=self.get_access_token(),
|
||||
)
|
||||
|
||||
if self.request.htmx:
|
||||
response = JsonResponse({
|
||||
'status': 'ok',
|
||||
'result': result,
|
||||
})
|
||||
|
||||
htmx_messages.add_htmx_message(
|
||||
request=self.request,
|
||||
response=response,
|
||||
level=htmx_messages.SUCCESS,
|
||||
message=_('The app auhtorization has been deleted.'),
|
||||
)
|
||||
|
||||
return trigger_client_event(
|
||||
response,
|
||||
'HotPocket:BrowseAccountAppsView:delete',
|
||||
{'pk': self.kwargs['pk']},
|
||||
after='swap',
|
||||
)
|
||||
|
||||
messages.add_message(
|
||||
self.request,
|
||||
messages.SUCCESS,
|
||||
_('The app auhtorization has been deleted.'),
|
||||
)
|
||||
|
||||
return super().form_valid(form)
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.contrib.auth.views import LoginView as BaseLoginView
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from hotpocket_backend.apps.core.conf import settings as django_settings
|
||||
from hotpocket_backend.apps.ui.forms.accounts.auth import LoginForm
|
||||
|
||||
|
||||
class LoginView(BaseLoginView):
|
||||
template_name = 'ui/accounts/login.html'
|
||||
form_class = LoginForm
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
request.session['post_login_next_url'] = request.GET.get('next', None)
|
||||
request.session.save()
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.post_login')
|
||||
|
||||
|
||||
class PostLoginView(RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs) -> str:
|
||||
next_url = self.request.session.pop('post_login_next_url', None)
|
||||
self.request.session.save()
|
||||
|
||||
allowed_hosts = None
|
||||
if len(django_settings.ALLOWED_HOSTS) > 0:
|
||||
allowed_hosts = set(filter(
|
||||
lambda value: value != '*',
|
||||
django_settings.ALLOWED_HOSTS,
|
||||
))
|
||||
|
||||
if next_url is not None:
|
||||
next_url_is_safe = url_has_allowed_host_and_scheme(
|
||||
url=next_url,
|
||||
allowed_hosts=allowed_hosts,
|
||||
require_https=self.request.is_secure(),
|
||||
)
|
||||
|
||||
if next_url_is_safe is False:
|
||||
next_url = None
|
||||
|
||||
return next_url or reverse('ui.index.index')
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
if request.user.is_anonymous is True:
|
||||
raise PermissionDenied('NOPE')
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
def logout(request: HttpRequest) -> HttpResponse:
|
||||
if request.user.is_authenticated is True:
|
||||
auth_logout(request)
|
||||
|
||||
return render(request, 'ui/accounts/logout.html')
|
||||
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
|
||||
|
||||
@account_required
|
||||
def browse(request: HttpRequest) -> HttpResponse:
|
||||
raise Http404()
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
|
||||
|
||||
@account_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.settings'))
|
||||
@@ -2,92 +2,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout as auth_logout
|
||||
from django.contrib.auth.views import LoginView as BaseLoginView
|
||||
from django.core.exceptions import PermissionDenied
|
||||
import django.db
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView, RedirectView
|
||||
from django.views.generic import FormView
|
||||
|
||||
from hotpocket_backend.apps.accounts.decorators import account_required
|
||||
from hotpocket_backend.apps.accounts.mixins import AccountRequiredMixin
|
||||
from hotpocket_backend.apps.core.conf import settings as django_settings
|
||||
from hotpocket_backend.apps.ui.forms.accounts import (
|
||||
from hotpocket_backend.apps.ui.forms.accounts.settings import (
|
||||
FederatedPasswordForm,
|
||||
FederatedProfileForm,
|
||||
LoginForm,
|
||||
PasswordForm,
|
||||
ProfileForm,
|
||||
SettingsForm,
|
||||
)
|
||||
|
||||
|
||||
@account_required
|
||||
def index(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.settings'))
|
||||
|
||||
|
||||
@account_required
|
||||
def browse(request: HttpRequest) -> HttpResponse:
|
||||
raise Http404()
|
||||
|
||||
|
||||
class LoginView(BaseLoginView):
|
||||
template_name = 'ui/accounts/login.html'
|
||||
form_class = LoginForm
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
request.session['post_login_next_url'] = request.GET.get('next', None)
|
||||
request.session.save()
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_success_url(self) -> str:
|
||||
return reverse('ui.accounts.post_login')
|
||||
|
||||
|
||||
class PostLoginView(RedirectView):
|
||||
def get_redirect_url(self, *args, **kwargs) -> str:
|
||||
next_url = self.request.session.pop('post_login_next_url', None)
|
||||
self.request.session.save()
|
||||
|
||||
allowed_hosts = None
|
||||
if len(django_settings.ALLOWED_HOSTS) > 0:
|
||||
allowed_hosts = set(filter(
|
||||
lambda value: value != '*',
|
||||
django_settings.ALLOWED_HOSTS,
|
||||
))
|
||||
|
||||
if next_url is not None:
|
||||
next_url_is_safe = url_has_allowed_host_and_scheme(
|
||||
url=next_url,
|
||||
allowed_hosts=allowed_hosts,
|
||||
require_https=self.request.is_secure(),
|
||||
)
|
||||
|
||||
if next_url_is_safe is False:
|
||||
next_url = None
|
||||
|
||||
return next_url or reverse('ui.index.index')
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
if request.user.is_anonymous is True:
|
||||
raise PermissionDenied('NOPE')
|
||||
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
|
||||
def logout(request: HttpRequest) -> HttpResponse:
|
||||
if request.user.is_authenticated is True:
|
||||
auth_logout(request)
|
||||
|
||||
return render(request, 'ui/accounts/logout.html')
|
||||
|
||||
|
||||
@account_required
|
||||
def settings(request: HttpRequest) -> HttpResponse:
|
||||
return redirect(reverse('ui.accounts.settings.profile'))
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import android # noqa: F401
|
||||
from . import extension # noqa: F401
|
||||
from . import ios # noqa: F401
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def authenticate(request: HttpRequest) -> HttpResponse:
|
||||
if request.user.is_anonymous is False:
|
||||
auth_key = str(uuid.uuid4())
|
||||
|
||||
request.session['extension_auth_key'] = auth_key
|
||||
request.session.save()
|
||||
|
||||
return redirect(reverse(
|
||||
'ui.integrations.extension.post_authenticate',
|
||||
query=[
|
||||
('auth_key', auth_key),
|
||||
],
|
||||
))
|
||||
|
||||
return redirect(reverse('ui.accounts.login', query=[
|
||||
('next', reverse('ui.integrations.extension.authenticate')),
|
||||
]))
|
||||
|
||||
|
||||
def post_authenticate(request: HttpRequest) -> HttpResponse:
|
||||
try:
|
||||
assert request.user.is_anonymous is False, 'Not authenticated'
|
||||
|
||||
auth_key = request.GET.get('auth_key', None)
|
||||
assert request.session.get('extension_auth_key', None) == auth_key, (
|
||||
'Auth key mismatch'
|
||||
)
|
||||
|
||||
return render(
|
||||
request, 'ui/integrations/extension/post_authenticate.html',
|
||||
)
|
||||
except AssertionError as exception:
|
||||
LOGGER.error(
|
||||
'Unable to handle extension authentication: %s',
|
||||
exception,
|
||||
exc_info=exception,
|
||||
)
|
||||
|
||||
raise PermissionDenied('NOPE')
|
||||
@@ -188,6 +188,7 @@ SITE_TITLE = 'HotPocket by BTHLabs'
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'hotpocket_backend.apps.accounts.backends.AccessTokenBackend',
|
||||
]
|
||||
|
||||
IMAGE_ID = os.getenv('HOTPOCKET_BACKEND_IMAGE_ID', 'development.00000000')
|
||||
|
||||
@@ -4,9 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from corsheaders.defaults import default_headers
|
||||
|
||||
from .base import * # noqa: F401,F403
|
||||
|
||||
INSTALLED_APPS += [ # noqa: F405
|
||||
'bthlabs_jsonrpc_django',
|
||||
'corsheaders',
|
||||
'crispy_forms',
|
||||
'crispy_bootstrap5',
|
||||
'django_htmx',
|
||||
@@ -16,9 +20,11 @@ MIDDLEWARE = [
|
||||
'hotpocket_backend.apps.core.middleware.RequestIDMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'hotpocket_backend.apps.accounts.middleware.AccessTokenMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'social_django.middleware.SocialAuthExceptionMiddleware',
|
||||
@@ -29,6 +35,9 @@ ROOT_URLCONF = 'hotpocket_backend.urls.webapp'
|
||||
|
||||
LOGIN_REDIRECT_URL = '/accounts/post-login/'
|
||||
|
||||
SESSION_COOKIE_SAMESITE = 'None'
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap5'
|
||||
CRISPY_TEMPLATE_PACK = 'bootstrap5'
|
||||
|
||||
@@ -56,3 +65,16 @@ SAVES_ASSOCIATION_ADAPTER = os.environ.get(
|
||||
'HOTPOCKET_BACKEND_SAVES_ASSOCIATION_ADAPTER',
|
||||
'hotpocket_backend.apps.saves.adapters.basic:BasicAssociationAdapter',
|
||||
)
|
||||
|
||||
JSONRPC_METHOD_MODULES = [
|
||||
'hotpocket_backend.apps.ui.rpc_methods',
|
||||
]
|
||||
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||
r'safari-web-extension:\/\/.+?',
|
||||
]
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
CORS_ALLOW_HEADERS = (
|
||||
*default_headers,
|
||||
'cookie',
|
||||
)
|
||||
|
||||