BTHLABS-50: Safari Web Extension: Reloaded

Turns out, getting this thing out into the wild isn't as simple as I thought :D
Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl>
Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
This commit is contained in:
Tomek Wójcik 2025-09-11 15:57:11 +00:00 committed by Tomek Wójcik
parent 67138c7035
commit dcebccf947
15 changed files with 456 additions and 55 deletions

View File

@ -479,7 +479,7 @@
"-framework", "-framework",
SafariServices, SafariServices,
); );
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.iOS.Extension; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.Extension;
PRODUCT_NAME = "HotPocket Extension"; PRODUCT_NAME = "HotPocket Extension";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -509,7 +509,7 @@
"-framework", "-framework",
SafariServices, SafariServices,
); );
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.iOS.Extension; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.Extension;
PRODUCT_NAME = "HotPocket Extension"; PRODUCT_NAME = "HotPocket Extension";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -525,7 +525,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (App)/Info.plist"; INFOPLIST_FILE = "iOS (App)/Info.plist";
@ -534,7 +534,7 @@
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UIMainStoryboardFile = Main;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -547,9 +547,13 @@
"-framework", "-framework",
WebKit, WebKit,
); );
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.iOS; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket;
PRODUCT_NAME = HotPocket; PRODUCT_NAME = HotPocket;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
}; };
@ -561,7 +565,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (App)/Info.plist"; INFOPLIST_FILE = "iOS (App)/Info.plist";
@ -570,7 +574,7 @@
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main; INFOPLIST_KEY_UIMainStoryboardFile = Main;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -583,9 +587,13 @@
"-framework", "-framework",
WebKit, WebKit,
); );
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.iOS; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket;
PRODUCT_NAME = HotPocket; PRODUCT_NAME = HotPocket;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
@ -617,7 +625,7 @@
"-framework", "-framework",
SafariServices, SafariServices,
); );
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.macOS.Extension; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.Extension;
PRODUCT_NAME = "HotPocket Extension"; PRODUCT_NAME = "HotPocket Extension";
SDKROOT = macosx; SDKROOT = macosx;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -629,7 +637,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/HotPocket.entitlements"; CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
@ -650,7 +658,7 @@
"-framework", "-framework",
SafariServices, SafariServices,
); );
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.macOS.Extension; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.Extension;
PRODUCT_NAME = "HotPocket Extension"; PRODUCT_NAME = "HotPocket Extension";
SDKROOT = macosx; SDKROOT = macosx;
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -686,7 +694,7 @@
"-framework", "-framework",
WebKit, WebKit,
); );
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.macOS; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket;
PRODUCT_NAME = HotPocket; PRODUCT_NAME = HotPocket;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = macosx; SDKROOT = macosx;
@ -700,7 +708,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "macOS (App)/HotPocket.entitlements"; CODE_SIGN_ENTITLEMENTS = "macOS (App)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 648728X64K; DEVELOPMENT_TEAM = 648728X64K;
@ -722,7 +730,7 @@
"-framework", "-framework",
WebKit, WebKit,
); );
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.macOS; PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket;
PRODUCT_NAME = HotPocket; PRODUCT_NAME = HotPocket;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
SDKROOT = macosx; SDKROOT = macosx;

View File

@ -11,7 +11,10 @@ import uuid6
from hotpocket_backend.apps.accounts.models import AccessToken from hotpocket_backend.apps.accounts.models import AccessToken
from hotpocket_backend.apps.core.conf import settings from hotpocket_backend.apps.core.conf import settings
from hotpocket_soa.dto.accounts import AccessTokensQuery from hotpocket_soa.dto.accounts import (
AccessTokenMetaUpdateIn,
AccessTokensQuery,
)
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@ -54,6 +57,16 @@ class AccessTokensService:
f'Access Token not found: pk=`{pk}`', f'Access Token not found: pk=`{pk}`',
) from exception ) from exception
def get_by_key(self, *, key: str) -> AccessToken:
try:
query_set = AccessToken.active_objects
return query_set.get(key=key)
except AccessToken.DoesNotExist as exception:
raise self.AccessTokenNotFound(
f'Access Token not found: key=`{key}`',
) from exception
def search(self, def search(self,
*, *,
query: AccessTokensQuery, query: AccessTokensQuery,
@ -79,3 +92,27 @@ class AccessTokensService:
access_token.soft_delete() access_token.soft_delete()
return True return True
def update_meta(self,
*,
pk: uuid.UUID,
update: AccessTokenMetaUpdateIn,
) -> AccessToken:
access_token = AccessToken.active_objects.get(pk=pk)
next_meta = {
**(access_token.meta or {}),
}
if update.version is not None:
next_meta['version'] = update.version
if update.platform is not None:
next_meta['platform'] = update.platform
access_token.meta = next_meta
access_token.save()
access_token.refresh_from_db()
return access_token

View File

@ -1,10 +1,62 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations from __future__ import annotations
import logging
from bthlabs_jsonrpc_core import register_method from bthlabs_jsonrpc_core import register_method
from django import db
from django.http import HttpRequest from django.http import HttpRequest
from hotpocket_soa.dto.accounts import AccessTokenMetaUpdateIn
from hotpocket_soa.services import AccessTokensService
LOGGER = logging.getLogger(__name__)
@register_method('accounts.auth.check') @register_method('accounts.auth.check')
def check(request: HttpRequest) -> bool: def check(request: HttpRequest) -> bool:
return request.user.is_anonymous is False return request.user.is_anonymous is False
@register_method('accounts.auth.check_access_token')
def check_access_token(request: HttpRequest,
access_token: str,
meta: dict | None = None,
) -> bool:
result = True
try:
access_tokens_service = AccessTokensService()
with db.transaction.atomic():
access_token_object = access_tokens_service.get_by_key(
account_uuid=request.user.pk,
key=access_token,
)
meta_update = AccessTokenMetaUpdateIn.model_validate(
(meta or {}),
)
_ = access_tokens_service.update_meta(
access_token=access_token_object,
update=meta_update,
)
except AccessTokensService.AccessTokenNotFound as exception:
LOGGER.error(
'Access Token not found: account_uuid=`%s` key=`%s`',
request.user.pk,
access_token,
exc_info=exception,
)
result = False
except AccessTokensService.AccessTokenAccessDenied as exception:
LOGGER.error(
'Access Token access denied: account_uuid=`%s` key=`%s`',
request.user.pk,
access_token,
exc_info=exception,
)
result = False
return result

View File

@ -11,7 +11,7 @@ from hotpocket_backend.apps.accounts.models import AccessToken
def AccessTokenMetaFactory() -> dict: def AccessTokenMetaFactory() -> dict:
return { return {
'platform': 'MacIntel', 'platform': 'MacIntel',
'version': '1987.10.03', 'version': '1985.12.12',
} }
@ -19,7 +19,7 @@ class AccessTokenFactory(factory.django.DjangoModelFactory):
account_uuid = None account_uuid = None
key = factory.LazyFunction(lambda: str(uuid.uuid4())) key = factory.LazyFunction(lambda: str(uuid.uuid4()))
origin = factory.LazyFunction( origin = factory.LazyFunction(
lambda: f'safari-web-extension//{uuid.uuid4()}', lambda: f'safari-web-extension://{uuid.uuid4()}',
) )
meta = factory.LazyFunction(AccessTokenMetaFactory) meta = factory.LazyFunction(AccessTokenMetaFactory)

View File

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

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import uuid
import pytest
@pytest.fixture
def safari_extension_origin():
return f'safari-web-extension://{uuid.uuid4()}'
@pytest.fixture
def safari_extension_meta():
return {
'platform': 'MacIntel',
'version': '1987.10.03',
}

View File

@ -24,15 +24,28 @@ class AccessTokensTestingService:
assert access_token.updated_at is not None assert access_token.updated_at is not None
def assert_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None): def assert_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = AccessToken.objects.get(pk=pk) access_token = AccessToken.objects.get(pk=pk)
assert association.deleted_at is not None assert access_token.deleted_at is not None
if reference is not None: if reference is not None:
assert association.updated_at > reference.updated_at assert access_token.updated_at > reference.updated_at
def assert_not_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None): def assert_not_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = AccessToken.objects.get(pk=pk) access_token = AccessToken.objects.get(pk=pk)
assert association.deleted_at is None assert access_token.deleted_at is None
if reference is not None: if reference is not None:
assert association.updated_at == reference.updated_at assert access_token.updated_at == reference.updated_at
def assert_meta_updated(self, *, pk: uuid.UUID, meta_update: dict, reference: typing.Any = None):
access_token = AccessToken.objects.get(pk=pk)
if len(meta_update) > 0 and reference is not None:
expected_meta = {
**reference.meta,
**meta_update,
}
assert access_token.meta == expected_meta
if reference is not None:
assert access_token.updated_at > reference.updated_at

View File

@ -14,29 +14,16 @@ from hotpocket_backend_testing.services.accounts import (
) )
@pytest.fixture
def origin():
return f'safari-web-extension://{uuid.uuid4()}'
@pytest.fixture @pytest.fixture
def auth_key(): def auth_key():
return str(uuid.uuid4()) return str(uuid.uuid4())
@pytest.fixture @pytest.fixture
def meta(): def call(rpc_call_factory, auth_key, safari_extension_meta):
return {
'platform': 'MacIntel',
'version': '1987.10.03',
}
@pytest.fixture
def call(rpc_call_factory, auth_key, meta):
return rpc_call_factory( return rpc_call_factory(
'accounts.access_tokens.create', 'accounts.access_tokens.create',
[auth_key, meta], [auth_key, safari_extension_meta],
) )
@ -44,9 +31,9 @@ def call(rpc_call_factory, auth_key, meta):
def test_ok(authenticated_client: Client, def test_ok(authenticated_client: Client,
auth_key, auth_key,
call, call,
origin, safari_extension_origin,
account, account,
meta, safari_extension_meta,
): ):
# Given # Given
session = authenticated_client.session session = authenticated_client.session
@ -59,7 +46,7 @@ def test_ok(authenticated_client: Client,
data=call, data=call,
content_type='application/json', content_type='application/json',
headers={ headers={
'Origin': origin, 'Origin': safari_extension_origin,
}, },
) )
@ -72,8 +59,8 @@ def test_ok(authenticated_client: Client,
AccessTokensTestingService().assert_created( AccessTokensTestingService().assert_created(
key=call_result['result'], key=call_result['result'],
account_uuid=account.pk, account_uuid=account.pk,
origin=origin, origin=safari_extension_origin,
meta=meta, meta=safari_extension_meta,
) )
assert 'extension_auth_key' not in authenticated_client.session assert 'extension_auth_key' not in authenticated_client.session
@ -82,7 +69,7 @@ def test_ok(authenticated_client: Client,
@pytest.mark.django_db @pytest.mark.django_db
def test_auth_key_missing(authenticated_client: Client, def test_auth_key_missing(authenticated_client: Client,
call, call,
origin, safari_extension_origin,
): ):
# When # When
result = authenticated_client.post( result = authenticated_client.post(
@ -90,7 +77,7 @@ def test_auth_key_missing(authenticated_client: Client,
data=call, data=call,
content_type='application/json', content_type='application/json',
headers={ headers={
'Origin': origin, 'Origin': safari_extension_origin,
}, },
) )
@ -105,7 +92,7 @@ def test_auth_key_missing(authenticated_client: Client,
@pytest.mark.django_db @pytest.mark.django_db
def test_auth_key_mismatch(authenticated_client: Client, def test_auth_key_mismatch(authenticated_client: Client,
call, call,
origin, safari_extension_origin,
): ):
# Given # Given
session = authenticated_client.session session = authenticated_client.session
@ -118,7 +105,7 @@ def test_auth_key_mismatch(authenticated_client: Client,
data=call, data=call,
content_type='application/json', content_type='application/json',
headers={ headers={
'Origin': origin, 'Origin': safari_extension_origin,
}, },
) )

View File

@ -0,0 +1,205 @@
# -*- coding: utf-8 -*-
# type: ignore
from __future__ import annotations
import http
from django.test import Client
from django.urls import reverse
import pytest
from hotpocket_backend_testing.services.accounts import (
AccessTokensTestingService,
)
@pytest.fixture
def call_factory(request: pytest.FixtureRequest, rpc_call_factory):
default_access_token = request.getfixturevalue('access_token_out')
default_meta_update = request.getfixturevalue('safari_extension_meta')
def factory(access_token=None, meta_update=None):
return rpc_call_factory(
'accounts.auth.check_access_token',
[
(
access_token.key
if access_token is not None
else default_access_token.key
),
(
meta_update
if meta_update is not None
else default_meta_update
),
],
)
return factory
@pytest.fixture
def call(call_factory):
return call_factory()
@pytest.mark.django_db
def test_ok(authenticated_client: Client,
call,
access_token_out,
safari_extension_meta,
):
# 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
AccessTokensTestingService().assert_meta_updated(
pk=access_token_out.pk,
meta_update=safari_extension_meta,
reference=access_token_out,
)
@pytest.mark.parametrize(
'meta_keys_to_pop',
[
('platform',),
('version',),
('platform', 'version'),
],
)
@pytest.mark.django_db
def test_ok_with_partial_meta_update(meta_keys_to_pop,
safari_extension_meta,
authenticated_client: Client,
call_factory,
access_token_out,
):
# Given
meta_update = {**safari_extension_meta}
for meta_key_to_pop in meta_keys_to_pop:
meta_update.pop(meta_key_to_pop)
call = call_factory(meta_update=meta_update)
# 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
AccessTokensTestingService().assert_meta_updated(
pk=access_token_out.pk,
meta_update=meta_update,
reference=access_token_out,
)
@pytest.mark.django_db
def test_invalid_access_token(authenticated_client: Client,
call,
):
# Given
call['params'][0] = 'thisisntright'
# 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 False
@pytest.mark.django_db
def test_deleted_access_token(call_factory,
deleted_access_token_out,
authenticated_client: Client,
):
# Given
call = call_factory(access_token=deleted_access_token_out)
# 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 False
@pytest.mark.django_db
def test_other_account_access_token(call_factory,
other_account_access_token_out,
authenticated_client: Client,
):
# Given
call = call_factory(access_token=other_account_access_token_out)
# 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 False
@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,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hotpocket.app</title>
<meta name="robots" content="noindex, nofollow">
</head>
<body>
<h1>SOON</h1>
</body>
</html>

View File

@ -66,6 +66,10 @@ const manifestJsonOutputPlugin = () => {
result.version = packageJSON.version; result.version = packageJSON.version;
if (IS_PRODUCTION === false) {
result.name = 'HotPocket Development';
}
return JSON.stringify(result, null, 2); return JSON.stringify(result, null, 2);
}, },
}; };

View File

@ -29,9 +29,12 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => {
let result = null; let result = null;
let error = null; let error = null;
const effectiveURL = new URL(url);
effectiveURL.searchParams.append('method', call.method);
try { try {
const response = await fetch( const response = await fetch(
url, effectiveURL.toString(),
{ {
body: JSON.stringify(call), body: JSON.stringify(call),
credentials: 'include', credentials: 'include',
@ -78,6 +81,13 @@ const executeJSONRPCCall = async (url, call, {accessToken}) => {
return [result, error]; return [result, error];
}; };
const getAccessTokenMeta = () => {
return {
platform: navigator.platform,
version: HotPocketExtension.version,
};
};
const doSave = async (accessToken, tab) => { const doSave = async (accessToken, tab) => {
const call = makeJSONRPCCall('saves.create', [tab.url]); const call = makeJSONRPCCall('saves.create', [tab.url]);
const [result, error] = await executeJSONRPCCall(RPC_URL, call, {accessToken}); const [result, error] = await executeJSONRPCCall(RPC_URL, call, {accessToken});
@ -97,10 +107,7 @@ const doCreateAndStoreAccessToken = async (authKey) => {
'accounts.access_tokens.create', 'accounts.access_tokens.create',
[ [
authKey, authKey,
{ getAccessTokenMeta(),
platform: navigator.platform,
version: HotPocketExtension.version,
},
], ],
); );
@ -163,7 +170,10 @@ const doCheckAuth = async (accessToken) => {
return null; return null;
} }
const call = makeJSONRPCCall('accounts.auth.check'); const call = makeJSONRPCCall(
'accounts.auth.check_access_token',
[accessToken, getAccessTokenMeta()],
);
const [result, error] = await executeJSONRPCCall(RPC_URL, call, { const [result, error] = await executeJSONRPCCall(RPC_URL, call, {
accessToken, accessToken,

View File

@ -4,6 +4,7 @@
"scripts": [ "scripts": [
"background-bundle.js" "background-bundle.js"
], ],
"type": "module" "type": "module",
"persistent": false
} }
} }

View File

@ -36,3 +36,8 @@ class AccessTokenOut(ModelOut):
class AccessTokensQuery(Query): class AccessTokensQuery(Query):
account_uuid: uuid.UUID account_uuid: uuid.UUID
before: uuid.UUID | None = pydantic.Field(default=None) before: uuid.UUID | None = pydantic.Field(default=None)
class AccessTokenMetaUpdateIn(pydantic.BaseModel):
version: str | None = None
platform: str | None = None

View File

@ -6,7 +6,11 @@ import uuid
from hotpocket_backend.apps.accounts.services import ( from hotpocket_backend.apps.accounts.services import (
AccessTokensService as BackendAccessTokensService, AccessTokensService as BackendAccessTokensService,
) )
from hotpocket_soa.dto.accounts import AccessTokenOut, AccessTokensQuery from hotpocket_soa.dto.accounts import (
AccessTokenMetaUpdateIn,
AccessTokenOut,
AccessTokensQuery,
)
from .base import ProxyService, SOAError from .base import ProxyService, SOAError
@ -76,6 +80,33 @@ class AccessTokensService(ProxyService):
else: else:
raise raise
def get_by_key(self,
*,
account_uuid: uuid.UUID,
key: str,
) -> AccessTokenOut:
try:
result = AccessTokenOut.model_validate(
self.call(
self.backend_access_tokens_service,
'get_by_key',
key=key,
),
from_attributes=True,
)
if result.account_uuid != account_uuid:
raise self.AccessTokenAccessDenied(
f'account_uuid=`{account_uuid}` key=`{key}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True:
raise self.AccessTokenNotFound(f'account_uuid=`{account_uuid}` pk=`{key}`') from exception
else:
raise
def search(self, def search(self,
*, *,
query: AccessTokensQuery, query: AccessTokensQuery,
@ -98,3 +129,18 @@ class AccessTokensService(ProxyService):
'delete', 'delete',
pk=access_token.pk, pk=access_token.pk,
) )
def update_meta(self,
*,
access_token: AccessTokenOut,
update: AccessTokenMetaUpdateIn,
) -> AccessTokenOut:
return AccessTokenOut.model_validate(
self.call(
self.backend_access_tokens_service,
'update_meta',
pk=access_token.pk,
update=update,
),
from_attributes=True,
)