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

@@ -64,4 +64,10 @@ export default defineConfig([
'no-invalid-this': 'off',
},
},
{
ignores: [
'hotpocket_backend/apps/**/static/**/*.js',
'hotpocket_backend/static/**/*.js',
],
},
]);

View File

@@ -1 +1,2 @@
from .access_token import AccessToken # noqa: F401
from .account import AccountAdmin # noqa: F401

View File

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

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

View File

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

View File

@@ -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',
},
),
]

View File

@@ -1 +1,2 @@
from .access_token import AccessToken # noqa: F401
from .account import Account # noqa: F401

View File

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

View File

@@ -0,0 +1 @@
from .access_tokens import AccessTokensService # noqa: F401

View File

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

View 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

View File

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

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from .base import BrowseParams as BaseBrowseParams
class AppsBrowseParams(BaseBrowseParams):
pass

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
from . import accounts # noqa: F401
from . import saves # noqa: F401

View File

@@ -0,0 +1,2 @@
from . import access_tokens # noqa: F401
from . import auth # noqa: F401

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }}"]'

View File

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

View File

@@ -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">
(() => {

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
"private": true,
"type": "module",
"scripts": {
"eslint": "npx eslint"
"eslint": "npx eslint ."
},
"devDependencies": {
"@eslint/js": "9.33.0",

View File

@@ -103,6 +103,40 @@ urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >
[package.extras]
crt = ["awscrt (==0.23.8)"]
[[package]]
name = "bthlabs-jsonrpc-core"
version = "1.1.0"
description = "BTHLabs JSONRPC - Core"
optional = false
python-versions = ">=3.10,<4.0"
files = [
{file = "bthlabs_jsonrpc_core-1.1.0-py3-none-any.whl", hash = "sha256:2ea4652a187c1406eb958c1cf64b24b4a475e817d46b506df6e5f6d14adf89b2"},
{file = "bthlabs_jsonrpc_core-1.1.0.tar.gz", hash = "sha256:15280018689b06743e3dc08355428037d64dade9ac8f7bb59f232959884d1874"},
]
[package.extras]
jwt = ["python-jose[cryptography] (>=3.3.0,<4.0)", "pytz (>=2023.3.post1)"]
[package.source]
type = "legacy"
url = "https://nexus.bthlabs.pl/repository/pypi/simple"
reference = "nexus"
[[package]]
name = "bthlabs-jsonrpc-django"
version = "1.2.0"
description = "BTHLabs JSONRPC - Django integration"
optional = false
python-versions = "<4.0,>=3.10"
files = [
{file = "bthlabs_jsonrpc_django-1.2.0-py3-none-any.whl", hash = "sha256:a19c65bbc534de2cd1b98a7651ac25aad83e5e7d91381f8f0df3cf734351617e"},
{file = "bthlabs_jsonrpc_django-1.2.0.tar.gz", hash = "sha256:a384d9a7f6ca151dfbf3fda828413a3c823551fc09b7e8a1bdc3aa3a208cf8f8"},
]
[package.dependencies]
bthlabs-jsonrpc-core = "1.1.0"
django = ">=4.2,<5.3"
[[package]]
name = "celery"
version = "5.5.3"
@@ -554,6 +588,21 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"]
[[package]]
name = "django-cors-headers"
version = "4.7.0"
description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)."
optional = false
python-versions = ">=3.9"
files = [
{file = "django_cors_headers-4.7.0-py3-none-any.whl", hash = "sha256:f1c125dcd58479fe7a67fe2499c16ee38b81b397463cf025f0e2c42937421070"},
{file = "django_cors_headers-4.7.0.tar.gz", hash = "sha256:6fdf31bf9c6d6448ba09ef57157db2268d515d94fc5c89a0a1028e1fc03ee52b"},
]
[package.dependencies]
asgiref = ">=3.6"
django = ">=4.2"
[[package]]
name = "django-crispy-forms"
version = "2.4"
@@ -2287,4 +2336,4 @@ brotli = ["brotli"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "3d8cf7ddb06917472eed4724058178f36c17f40db891a19d13f5d0d758e61102"
content-hash = "1daff90b61807314aa591cd4c4aa62107594c13d9126e2a3091eec541b9cd039"

View File

@@ -6,11 +6,18 @@ authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0"
readme = "README.md"
[[tool.poetry.source]]
name = "nexus"
url = "https://nexus.bthlabs.pl/repository/pypi/simple/"
priority = "supplemental"
[tool.poetry.dependencies]
python = "^3.12"
bthlabs-jsonrpc-django = "1.2.0"
celery = "5.5.3"
crispy-bootstrap5 = "2025.6"
django = "5.2.3"
django-cors-headers = "4.7.0"
django-crispy-forms = "2.4"
django-htmx = "1.23.2"
hotpocket-common = {path = "../packages/common", develop = true}

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
import functools
import os
from invoke import task
@@ -49,7 +48,7 @@ def isort(ctx, check=False, diff=False):
@task
def eslint(ctx):
ctx.run('npx eslint')
ctx.run('yarn run eslint')
@task
@@ -89,12 +88,7 @@ def django_shell(ctx):
def ci(ctx):
ihazsuccess = True
ci_tasks = [
test,
flake8,
functools.partial(isort, check=True),
typecheck,
]
ci_tasks = [test, lint, typecheck]
for ci_task in ci_tasks:
try:
ci_task(ctx)

View File

@@ -0,0 +1,2 @@
from .access_token import AccessTokenFactory # noqa: F401,F403
from .account import AccountFactory # noqa: F401,F403

View File

@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import uuid
import factory
from hotpocket_backend.apps.accounts.models import AccessToken
def AccessTokenMetaFactory() -> dict:
return {
'platform': 'MacIntel',
'version': '1987.10.03',
}
class AccessTokenFactory(factory.django.DjangoModelFactory):
account_uuid = None
key = factory.LazyFunction(lambda: str(uuid.uuid4()))
origin = factory.LazyFunction(
lambda: f'safari-web-extension//{uuid.uuid4()}',
)
meta = factory.LazyFunction(AccessTokenMetaFactory)
class Meta:
model = AccessToken

View File

@@ -0,0 +1,2 @@
from .access_token import * # noqa: F401,F403
from .account import * # noqa: F401,F403

View File

@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from django.utils.timezone import now
import pytest
from hotpocket_soa.dto.accounts import AccessTokenOut
@pytest.fixture
def access_token_factory(request: pytest.FixtureRequest):
default_account = request.getfixturevalue('account')
def factory(account=None, **kwargs):
from hotpocket_backend_testing.factories.accounts import (
AccessTokenFactory,
)
return AccessTokenFactory(
account_uuid=(
account.pk
if account is not None
else default_account.pk
),
**kwargs,
)
return factory
@pytest.fixture
def access_token(access_token_factory):
return access_token_factory()
@pytest.fixture
def access_token_out(access_token):
return AccessTokenOut.model_validate(access_token, from_attributes=True)
@pytest.fixture
def deleted_access_token(access_token_factory):
return access_token_factory(deleted_at=now())
@pytest.fixture
def deleted_access_token_out(deleted_access_token):
return AccessTokenOut.model_validate(deleted_access_token, from_attributes=True)
@pytest.fixture
def other_access_token(access_token_factory):
return access_token_factory()
@pytest.fixture
def other_access_token_out(other_access_token):
return AccessTokenOut.model_validate(other_access_token, from_attributes=True)
@pytest.fixture
def inactive_account_access_token(access_token_factory, inactive_account):
return access_token_factory(account=inactive_account)
@pytest.fixture
def inactive_account_access_token_out(access_token):
return AccessTokenOut.model_validate(
inactive_account_access_token, from_attributes=True,
)
@pytest.fixture
def other_account_access_token(access_token_factory, other_account):
return access_token_factory(account=other_account)
@pytest.fixture
def other_account_access_token_out(other_account_access_token):
return AccessTokenOut.model_validate(
other_account_access_token, from_attributes=True,
)
@pytest.fixture
def browsable_access_tokens(access_token,
other_access_token,
other_account_access_token,
):
return [
access_token,
other_access_token,
]
@pytest.fixture
def browsable_access_token_outs(browsable_access_tokens):
return [
AccessTokenOut.model_validate(obj, from_attributes=True)
for obj
in browsable_access_tokens
]
@pytest.fixture
def paginatable_access_tokens(access_token_factory):
result = [
access_token_factory()
for _ in range(0, 12)
]
return result[::-1]
@pytest.fixture
def paginatable_access_token_outs(paginatable_access_tokens):
return [
AccessTokenOut.model_validate(obj, from_attributes=True)
for obj
in paginatable_access_tokens
]

View File

@@ -0,0 +1,2 @@
from .access_tokens import AccessTokensTestingService # noqa: F401
from .accounts import AccountsTestingService # noqa: F401

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
import uuid
from hotpocket_backend.apps.accounts.models import AccessToken
class AccessTokensTestingService:
def assert_created(self,
*,
key: str,
account_uuid: uuid.UUID,
origin: str,
meta: dict,
):
access_token = AccessToken.objects.get(key=key)
assert access_token.account_uuid == account_uuid
assert access_token.origin == origin
assert access_token.meta == meta
assert access_token.created_at is not None
assert access_token.updated_at is not None
def assert_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = AccessToken.objects.get(pk=pk)
assert association.deleted_at is not None
if reference is not None:
assert association.updated_at > reference.updated_at
def assert_not_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = AccessToken.objects.get(pk=pk)
assert association.deleted_at is None
if reference is not None:
assert association.updated_at == reference.updated_at

View File

@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import http
from django.test import Client
from django.urls import reverse
import pytest
from pytest_django import asserts
def assert_default_mode_response_context(response, access_tokens):
expected_access_token_ids = list(sorted(
[obj.pk for obj in access_tokens],
reverse=True,
))
assert len(response.context['access_tokens']) == 2
assert response.context['access_tokens'][0].pk == expected_access_token_ids[0]
assert response.context['access_tokens'][1].pk == expected_access_token_ids[1]
assert response.context['before'] is None
assert response.context['next_url'] is None
@pytest.mark.django_db
def test_ok(browsable_access_token_outs,
authenticated_client: Client,
):
# When
result = authenticated_client.get(
reverse('ui.accounts.apps.browse'),
)
# Then
assert result.status_code == http.HTTPStatus.OK
asserts.assertTemplateUsed(result, 'ui/accounts/apps/browse.html')
assert_default_mode_response_context(result, browsable_access_token_outs)
@pytest.mark.django_db
def test_ok_htmx(browsable_access_token_outs,
authenticated_client: Client,
):
# When
result = authenticated_client.get(
reverse('ui.accounts.apps.browse'),
headers={
'HX-Request': 'true',
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
asserts.assertTemplateNotUsed(result, 'ui/accounts/apps/browse.html')
asserts.assertTemplateUsed(result, 'ui/accounts/partials/apps/apps.html')
assert_default_mode_response_context(result, browsable_access_token_outs)
@pytest.mark.parametrize(
'query_before_index,expected_before_index,expected_length,first_index,last_index',
[
(None, 9, 10, 0, 9),
(9, None, 2, 10, 11),
],
)
@pytest.mark.django_db
def test_pagination(query_before_index,
expected_before_index,
expected_length,
first_index,
last_index,
paginatable_access_token_outs,
authenticated_client: Client,
):
# Given
request_data = {}
if query_before_index is not None:
request_data['before'] = str(
paginatable_access_token_outs[query_before_index].pk,
)
# When
result = authenticated_client.get(
reverse('ui.accounts.apps.browse'),
data=request_data,
)
# Then
assert result.status_code == http.HTTPStatus.OK
expected_before = None
expected_next_url = None
if expected_before_index:
expected_before = paginatable_access_token_outs[expected_before_index].pk
expected_next_url = reverse(
'ui.accounts.apps.browse',
query=[
('before', expected_before),
('limit', 10),
],
)
assert len(result.context['access_tokens']) == expected_length
assert result.context['access_tokens'][0].pk == paginatable_access_token_outs[first_index].pk
assert result.context['access_tokens'][-1].pk == paginatable_access_token_outs[last_index].pk
assert result.context['before'] == expected_before
assert result.context['next_url'] == expected_next_url
@pytest.mark.django_db
def test_inactive_account(inactive_account_client: Client):
# When
result = inactive_account_client.get(
reverse('ui.accounts.apps.browse'),
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.accounts.login',
query=[
('next', reverse('ui.accounts.apps.browse')),
],
),
fetch_redirect_response=False,
)
@pytest.mark.django_db
def test_anonymous(client: Client):
# When
result = client.get(
reverse('ui.accounts.apps.browse'),
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.accounts.login',
query=[
('next', reverse('ui.accounts.apps.browse')),
],
),
fetch_redirect_response=False,
)

View File

@@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import http
from django.test import Client
from django.urls import reverse
import pytest
from pytest_django import asserts
from hotpocket_backend_testing.services.accounts import (
AccessTokensTestingService,
)
@pytest.mark.django_db
def test_ok(authenticated_client: Client,
access_token_out,
):
# When
result = authenticated_client.post(
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
asserts.assertRedirects(
result,
reverse('ui.accounts.apps.browse'),
fetch_redirect_response=False,
)
AccessTokensTestingService().assert_deleted(
pk=access_token_out.pk, reference=access_token_out,
)
@pytest.mark.django_db
def test_ok_htmx(authenticated_client: Client,
access_token_out,
):
# When
result = authenticated_client.post(
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
headers={
'HX-Request': 'true',
},
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
expected_payload = {
'status': 'ok',
'result': True,
}
assert result.json() == expected_payload
@pytest.mark.django_db
def test_invalid_all_missing(authenticated_client: Client,
access_token_out,
):
# When
result = authenticated_client.post(
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
data={
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
AccessTokensTestingService().assert_not_deleted(
pk=access_token_out.pk, reference=access_token_out,
)
assert 'canhazconfirm' in result.context['form'].errors
@pytest.mark.django_db
def test_invalid_all_empty(authenticated_client: Client,
access_token_out,
):
# When
result = authenticated_client.post(
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
data={
'canhazconfirm': '',
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
AccessTokensTestingService().assert_not_deleted(
pk=access_token_out.pk, reference=access_token_out,
)
assert 'canhazconfirm' in result.context['form'].errors
@pytest.mark.django_db
def test_other_account_access_token(authenticated_client: Client,
other_account_access_token_out,
):
# When
result = authenticated_client.post(
reverse('ui.accounts.apps.delete', args=(other_account_access_token_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_inactive_account(inactive_account_client: Client,
access_token_out,
):
# When
result = inactive_account_client.post(
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.accounts.login',
query=[
('next', reverse('ui.accounts.apps.delete', args=(access_token_out.pk,))),
],
),
fetch_redirect_response=False,
)
@pytest.mark.django_db
def test_anonymous(client: Client,
access_token_out,
):
# When
result = client.post(
reverse('ui.accounts.apps.delete', args=(access_token_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.accounts.login',
query=[
('next', reverse('ui.accounts.apps.delete', args=(access_token_out.pk,))),
],
),
fetch_redirect_response=False,
)

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
from django.test import Client
from django.urls import reverse
import pytest
from pytest_django import asserts
@pytest.mark.django_db
def test_ok(authenticated_client: Client):
# When
result = authenticated_client.get(
reverse('ui.accounts.apps.index'),
follow=False,
)
# Then
asserts.assertRedirects(
result,
reverse('ui.accounts.apps.browse'),
fetch_redirect_response=False,
)
@pytest.mark.django_db
def test_inactive_account(inactive_account_client: Client):
# When
result = inactive_account_client.get(
reverse('ui.accounts.apps.index'),
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.accounts.login',
query=[('next', reverse('ui.accounts.apps.index'))],
),
fetch_redirect_response=False,
)
@pytest.mark.django_db
def test_anonymous(client: Client):
# When
result = client.get(
reverse('ui.accounts.apps.index'),
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.accounts.login',
query=[('next', reverse('ui.accounts.apps.index'))],
),
fetch_redirect_response=False,
)

View File

@@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import http
from django.test import Client
from django.urls import reverse
import pytest
from pytest_django import asserts
from hotpocket_common.url import URL
@pytest.mark.django_db
def test_ok(authenticated_client: Client):
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.authenticate'),
follow=False,
)
# Then
assert result.status_code == http.HTTPStatus.FOUND
assert 'Location' in result.headers
redirect_url = URL(result.headers['Location'])
assert redirect_url.raw_path == reverse('ui.integrations.extension.post_authenticate')
assert 'auth_key' in redirect_url.query
assert 'extension_auth_key' in authenticated_client.session
assert authenticated_client.session['extension_auth_key'] == redirect_url.query['auth_key'][0]
@pytest.mark.django_db
def test_inactive_account(inactive_account_client: Client):
# When
result = inactive_account_client.get(
reverse('ui.integrations.extension.authenticate'),
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.accounts.login',
query=[('next', reverse('ui.integrations.extension.authenticate'))],
),
fetch_redirect_response=False,
)
@pytest.mark.django_db
def test_anonymous(client: Client):
# When
result = client.get(
reverse('ui.integrations.extension.authenticate'),
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.accounts.login',
query=[('next', reverse('ui.integrations.extension.authenticate'))],
),
fetch_redirect_response=False,
)

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import http
import uuid
from django.test import Client
from django.urls import reverse
import pytest
from pytest_django import asserts
@pytest.fixture
def auth_key():
return str(uuid.uuid4())
@pytest.mark.django_db
def test_ok(authenticated_client: Client, auth_key):
# Given
session = authenticated_client.session
session['extension_auth_key'] = auth_key
session.save()
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.post_authenticate'),
data={
'auth_key': auth_key,
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
asserts.assertTemplateUsed(
result, 'ui/integrations/extension/post_authenticate.html',
)
@pytest.mark.django_db
def test_auth_key_not_in_session(authenticated_client: Client, auth_key):
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.post_authenticate'),
data={
'auth_key': auth_key,
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_auth_key_not_request(authenticated_client: Client, auth_key):
# Given
session = authenticated_client.session
session['extension_auth_key'] = auth_key
session.save()
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.post_authenticate'),
data={
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_auth_key_mismatch(authenticated_client: Client, auth_key):
# Given
session = authenticated_client.session
session['extension_auth_key'] = auth_key
session.save()
# When
result = authenticated_client.get(
reverse('ui.integrations.extension.post_authenticate'),
data={
'auth_key': 'thisisntright',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_inactive_account(inactive_account_client: Client):
# When
result = inactive_account_client.get(
reverse('ui.integrations.extension.post_authenticate'),
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_anonymous(client: Client):
# When
result = client.get(
reverse('ui.integrations.extension.post_authenticate'),
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN

View File

@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import http
import uuid
from django.test import Client
from django.urls import reverse
import pytest
from hotpocket_backend_testing.services.accounts import (
AccessTokensTestingService,
)
@pytest.fixture
def origin():
return f'safari-web-extension://{uuid.uuid4()}'
@pytest.fixture
def auth_key():
return str(uuid.uuid4())
@pytest.fixture
def meta():
return {
'platform': 'MacIntel',
'version': '1987.10.03',
}
@pytest.fixture
def call(rpc_call_factory, auth_key, meta):
return rpc_call_factory(
'accounts.access_tokens.create',
[auth_key, meta],
)
@pytest.mark.django_db
def test_ok(authenticated_client: Client,
auth_key,
call,
origin,
account,
meta,
):
# Given
session = authenticated_client.session
session['extension_auth_key'] = auth_key
session.save()
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
headers={
'Origin': origin,
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
AccessTokensTestingService().assert_created(
key=call_result['result'],
account_uuid=account.pk,
origin=origin,
meta=meta,
)
assert 'extension_auth_key' not in authenticated_client.session
@pytest.mark.django_db
def test_auth_key_missing(authenticated_client: Client,
call,
origin,
):
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
headers={
'Origin': origin,
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'] == 'Auth key missing'
@pytest.mark.django_db
def test_auth_key_mismatch(authenticated_client: Client,
call,
origin,
):
# Given
session = authenticated_client.session
session['extension_auth_key'] = 'thisisntright'
session.save()
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
headers={
'Origin': origin,
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'] == 'Auth key mismatch'
@pytest.mark.django_db
def test_inactive_account(inactive_account_client: Client, call):
# When
result = inactive_account_client.post(
reverse('ui.rpc'),
data=call,
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_anonymous(client: Client, call):
# When
result = client.post(
reverse('ui.rpc'),
data=call,
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN

View File

@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import http
from django.test import Client
from django.urls import reverse
import pytest
@pytest.fixture
def call(rpc_call_factory):
return rpc_call_factory(
'accounts.auth.check',
[],
)
@pytest.mark.django_db
def test_ok_session_auth(authenticated_client: Client,
call,
):
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
assert call_result['result'] is True
@pytest.mark.django_db
def test_session_auth_inactive_account(inactive_account_client: Client,
call,
):
# When
result = inactive_account_client.post(
reverse('ui.rpc'),
data=call,
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_ok_access_token_auth(client: Client,
call,
access_token_out,
):
# When
result = client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
headers={
'Authorization': f'Bearer {access_token_out.key}',
},
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
assert call_result['result'] is True
@pytest.mark.django_db
def test_access_token_auth_not_bearer(client: Client,
call,
access_token_out,
):
# When
result = client.post(
reverse('ui.rpc'),
data=call,
headers={
'Authorization': f'thisisntright {access_token_out.key}',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_access_token_auth_invalid_access_token(client: Client,
call,
null_uuid,
):
# When
result = client.post(
reverse('ui.rpc'),
data=call,
headers={
'Authorization': f'Bearer {null_uuid}',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_access_token_auth_deleted_access_token(client: Client,
call,
deleted_access_token,
):
# When
result = client.post(
reverse('ui.rpc'),
data=call,
headers={
'Authorization': f'Bearer {deleted_access_token.key}',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_access_token_auth_inactive_account(client: Client,
call,
inactive_account_access_token,
):
# When
result = client.post(
reverse('ui.rpc'),
data=call,
headers={
'Authorization': f'Bearer {inactive_account_access_token.key}',
},
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_anonymous(client: Client, call):
# When
result = client.post(
reverse('ui.rpc'),
data=call,
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN

View File

@@ -0,0 +1,287 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import http
from unittest import mock
import uuid
from django.test import Client
from django.urls import reverse
import pytest
import pytest_mock
from hotpocket_backend_testing.services.saves import (
AssociationsTestingService,
SaveProcessorTestingService,
SavesTestingService,
)
@pytest.fixture
def mock_saves_process_save_task_apply_async(mocker: pytest_mock.MockerFixture,
async_result,
) -> mock.Mock:
return SaveProcessorTestingService().mock_process_save_task_apply_async(
mocker=mocker, async_result=async_result,
)
@pytest.fixture
def call(rpc_call_factory):
return rpc_call_factory(
'saves.create',
['https://www.ziomek.dog/'],
)
@pytest.mark.django_db
def test_ok(authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
save_pk = uuid.UUID(call_result['result']['target_uuid'])
association_pk = uuid.UUID(call_result['result']['id'])
AssociationsTestingService().assert_created(
pk=association_pk,
account_uuid=account.pk,
target_uuid=save_pk,
)
SavesTestingService().assert_created(
pk=save_pk,
account_uuid=account.pk,
url=call['params'][0],
is_netloc_banned=False,
)
mock_saves_process_save_task_apply_async.assert_called_once_with(
kwargs={
'pk': save_pk,
},
)
@pytest.mark.django_db
def test_ok_netloc_banned(authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# Given
call['params'][0] = 'https://youtube.com/'
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
save_pk = uuid.UUID(call_result['result']['target_uuid'])
SavesTestingService().assert_created(
pk=save_pk,
account_uuid=account.pk,
url=call['params'][0],
is_netloc_banned=True,
)
@pytest.mark.django_db
def test_ok_resuse_save(save_out,
authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# Given
call['params'][0] = save_out.url
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
save_pk = uuid.UUID(call_result['result']['target_uuid'])
association_pk = uuid.UUID(call_result['result']['id'])
AssociationsTestingService().assert_created(
pk=association_pk,
account_uuid=account.pk,
target_uuid=save_pk,
)
SavesTestingService().assert_reused(
pk=save_pk,
reference=save_out,
)
@pytest.mark.django_db
def test_ok_resuse_association(association_out,
save_out,
authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# Given
call['params'][0] = save_out.url
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
association_pk = uuid.UUID(call_result['result']['id'])
AssociationsTestingService().assert_reused(
pk=association_pk,
reference=association_out,
)
@pytest.mark.django_db
def test_ok_reuse_other_account_save(other_account_save_out,
authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# Given
call['params'][0] = other_account_save_out.url
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' not in call_result
save_pk = uuid.UUID(call_result['result']['target_uuid'])
association_pk = uuid.UUID(call_result['result']['id'])
AssociationsTestingService().assert_created(
pk=association_pk,
account_uuid=account.pk,
target_uuid=save_pk,
)
SavesTestingService().assert_reused(
pk=save_pk,
reference=other_account_save_out,
)
@pytest.mark.django_db
def test_ok_dont_process_reused_processed_save(processed_save_out,
authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# Given
call['params'][0] = processed_save_out.url
# When
_ = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
)
# Then
mock_saves_process_save_task_apply_async.assert_not_called()
@pytest.mark.django_db
def test_empty_url(authenticated_client: Client,
call,
account,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# Given
call['params'][0] = ''
# When
result = authenticated_client.post(
reverse('ui.rpc'),
data=call,
content_type='application/json',
)
# Then
assert result.status_code == http.HTTPStatus.OK
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data']['url'] == ['blank']
@pytest.mark.django_db
def test_inactive_account(inactive_account_client: Client, call):
# When
result = inactive_account_client.post(
reverse('ui.rpc'),
data=call,
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN
@pytest.mark.django_db
def test_anonymous(client: Client, call):
# When
result = client.post(
reverse('ui.rpc'),
data=call,
)
# Then
assert result.status_code == http.HTTPStatus.FORBIDDEN