BTHLABS-50: Safari Web extension

Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl>
Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
This commit is contained in:
2025-09-08 18:11:36 +00:00
committed by Tomek Wójcik
parent ffecf780ee
commit b6d02dbe78
184 changed files with 7536 additions and 163 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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')

View File

@@ -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()

View File

@@ -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'))

View File

@@ -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'))

View File

@@ -1,2 +1,3 @@
from . import android # noqa: F401
from . import extension # noqa: F401
from . import ios # noqa: F401

View File

@@ -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')