Compare commits

...

7 Commits

Author SHA1 Message Date
0ac2ca73ec Release v25.10.13
All checks were successful
CI / Checks (push) Successful in 3m58s
2025-10-13 21:46:18 +02:00
7b67a2f758 BTHLABS-62: Display progress in extension popup
Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl>
Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
2025-10-13 18:48:00 +00:00
8b86145519 BTHLABS-61: Service layer refactoring
A journey to fix `ValidationError` in Pocket imports turned service
layer refactoring :D
2025-10-12 20:54:00 +02:00
ac7a8dd90e BTHLABS-0000: README.md fixes 2025-10-07 08:46:50 +02:00
6903b7f768 BTHLABS-0000: Nuked dotcom service
Moved to a separate repo
2025-10-07 08:45:10 +02:00
2e8b8d7330 BTHLABS-60: Appearance settings
Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl>
Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
2025-10-07 04:42:58 +00:00
b4d5375954 BTHLABS-0000: Docker and CI tweaks
Co-authored-by: Tomek Wójcik <labs@tomekwojcik.pl>
Co-committed-by: Tomek Wójcik <labs@tomekwojcik.pl>
2025-10-07 04:37:01 +00:00
92 changed files with 1441 additions and 509 deletions

View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -e
set +x
set -o pipefail
cat >"./docker-compose-ci-${COMPOSE_PROJECT}.yaml" <<EOF
services:
postgres:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-${COMPOSE_PROJECT}"
keycloak:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-${COMPOSE_PROJECT}"
rabbitmq:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-${COMPOSE_PROJECT}"
apple-ci:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-${COMPOSE_PROJECT}"
backend-ci:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-${COMPOSE_PROJECT}"
extension-ci:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/extension:ci-${COMPOSE_PROJECT}"
packages-ci:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-${COMPOSE_PROJECT}"
EOF

View File

@ -17,8 +17,35 @@ jobs:
steps:
- name: "Checkout the code"
uses: "actions/checkout@v2"
- name: "Get run info"
id: "get-run-info"
run: |
set -x
echo "COMPOSE_PROJECT=${{ vars.COMPOSE_PROJECT_BASE }}-${GITHUB_RUN_NUMBER}" >> $GITHUB_OUTPUT
- name: "Get build options"
id: "get-build-options"
run: |
set -x
SHORT_SHA="${GITHUB_SHA::8}"
BUILD_ARCH="amd64"
BUILD_PLATFORM="linux/amd64"
if [ "${RUNNER_ARCH}" = "ARM64" ];then
BUILD_ARCH="arm64"
BUILD_PLATFORM="linux/arm64"
fi
echo "SHORT_SHA=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "BUILD_ARCH=$BUILD_ARCH" >> $GITHUB_OUTPUT
echo "BUILD_PLATFORM=$BUILD_PLATFORM" >> $GITHUB_OUTPUT
- name: "Set up Docker Buildx"
id: "setup-docker-buildx"
uses: "docker/setup-buildx-action@v3"
with:
driver: "remote"
endpoint: "tcp://builder-01.bthlab:2375"
platforms: "linux/amd64"
append: |
- endpoint: "tcp://builder-mac-01.bthlab:2375"
platforms: "linux/arm64"
- name: "Build `postgres` image"
uses: docker/build-push-action@v6
with:
@ -26,7 +53,10 @@ jobs:
context: "services/"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-local"
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/postgres:15.13-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `keycloak` image"
uses: docker/build-push-action@v6
with:
@ -34,7 +64,10 @@ jobs:
context: "services/"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-local"
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/keycloak:22.0.3-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `rabbitmq` image"
uses: docker/build-push-action@v6
with:
@ -42,7 +75,10 @@ jobs:
context: "services/"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-local"
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/rabbitmq:3.10.8-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `backend-ci` image"
uses: docker/build-push-action@v6
with:
@ -51,7 +87,10 @@ jobs:
target: "ci"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-local"
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:ci-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `packages-ci` image"
uses: docker/build-push-action@v6
with:
@ -60,7 +99,10 @@ jobs:
target: "ci"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-local"
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/packages:ci-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `extension-ci` image"
uses: docker/build-push-action@v6
with:
@ -69,23 +111,91 @@ jobs:
target: "ci"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/extension:ci-local"
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/extension:ci-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Build `apple-ci` image"
uses: docker/build-push-action@v6
with:
file: "services/apple/Dockerfile"
context: "services/"
target: "ci"
push: false
load: true
tags: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
platforms: "${{ steps.get-build-options.outputs.BUILD_PLATFORM }}"
cache-from: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket"
cache-to: "type=registry,ref=nexus.bthlab.bthlabs.net:8001/hotpocket,mode=max"
- name: "Prepare the build"
id: "prepare"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: |
set -x
./.gitea/tools/render-docker-compose-ci.sh
- name: "Run `backend` checks"
if: "steps.prepare.conclusion == 'success'"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: |
set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml run --rm backend-ci inv ci
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
run --rm \
backend-ci inv ci
- name: "Run `packages` checks"
if: always()
if: "steps.prepare.conclusion == 'success'"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: |
set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml run --rm packages-ci inv ci
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
run --rm \
packages-ci inv ci
- name: "Run `extension` checks"
if: always()
if: "steps.prepare.conclusion == 'success'"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: |
set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml run --rm extension-ci inv ci
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
run --rm \
extension-ci inv ci
- name: "Run `apple` checks"
if: "steps.prepare.conclusion == 'success'"
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: |
set -x
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
run --rm \
apple-ci inv ci
- name: "Clean up"
if: always()
env:
COMPOSE_PROJECT: "${{ steps.get-run-info.outputs.COMPOSE_PROJECT }}"
run: |
set -x
docker compose -f docker-compose.yaml -f docker-compose-ci.yaml down --volumes
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "docker-compose.yaml" \
-f "docker-compose-ci.yaml" \
-f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" \
down --volumes --rmi all || true
rm -f "docker-compose-ci-${COMPOSE_PROJECT}.yaml" || true

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.envrc*
.ipythonhome/
/docker-compose-ci-*.yaml

View File

@ -86,3 +86,5 @@ Licensed under terms of the MIT License
Pepper Hot Solid icon
Copyright (c) Icons8
Licensed under terms of the MIT License
Spinner Loader CSS from https://css-loaders.com/

View File

@ -66,7 +66,7 @@ $ docker run --rm -it \
-e HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME=hotpocket \
-e HOTPOCKET_BACKEND_INITIAL_ACCOUNT_PASSWORD=hotpocketm4st3r \
-p 8000:8000 \
docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v25.10.4-01
docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v25.10.13-01
```
The command above will set up and start the application. The SQLite file will
@ -76,8 +76,7 @@ credentials. The Web app will be reachable at `http://127.0.0.1:8000/`.
The admin will be reachable at `http://127.0.0.1:8000/admin/`.
The `DJANGO_SETTINGS_MODULE` environment variable defaults to
`hotpocket_backend.settings.deployment.webapp`. This should be set to
`hotpocket_backend.settings.deployment.admin` in the Admin container.
`hotpocket_backend.settings.deployment.aio`.
**NOTE:** The command above specifies wildly insecure `SECRET_KEY` which is
used among other things to secure the session cookie. Please *please*
@ -94,7 +93,8 @@ backend etc. The final deployment will require services for at least the Web
app, the Celery worker and Celery Beat. Admin is optional.
The `DJANGO_SETTINGS_MODULE` environment variable defaults to
`hotpocket_backend.settings.deployment.aio`.
`hotpocket_backend.settings.deployment.webapp`. This should be set to
`hotpocket_backend.settings.deployment.admin` in the Admin container.
The `deployment/fullstack/docker-compose.yaml` file can be used as a
starting point for full-stack deployments.

View File

@ -1,6 +1,6 @@
services:
backend:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v25.10.4-01"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:aio-v25.10.13-01"
environment:
HOTPOCKET_BACKEND_SECRET_KEY: "thisisntright"
HOTPOCKET_BACKEND_INITIAL_ACCOUNT_USERNAME: "hotpocket"

View File

@ -8,7 +8,7 @@ x-backend-environment: &x-backend-environment
services:
webapp:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.4-01"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.13-01"
environment:
<<: *x-backend-environment
HOTPOCKET_BACKEND_ALLOWED_HOSTS: "app.staging.hotpocket.bthlab.bthlabs.net"
@ -21,7 +21,7 @@ services:
restart: "unless-stopped"
admin:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.4-01"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.13-01"
environment:
<<: *x-backend-environment
HOTPOCKET_BACKEND_APP: "admin"
@ -35,7 +35,7 @@ services:
restart: "unless-stopped"
celery-worker:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.4-01"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.13-01"
command:
- "/srv/venv/bin/celery"
- "-A"
@ -57,7 +57,7 @@ services:
restart: "unless-stopped"
celery-beat:
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.4-01"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/backend:deployment-v25.10.13-01"
command:
- "/srv/venv/bin/celery"
- "-A"

View File

@ -2,6 +2,7 @@
"group": {
"default": {
"targets": [
"apple-management",
"backend-management",
"caddy",
"extension-management",
@ -13,6 +14,28 @@
}
},
"target": {
"apple-management": {
"context": "services/",
"dockerfile": "apple/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/apple:local"
],
"target": "development",
"output": [
"type=docker,load=true,push=false"
]
},
"apple-ci": {
"context": "services/",
"dockerfile": "apple/Dockerfile",
"tags": [
"docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-local"
],
"target": "ci",
"output": [
"type=docker,load=true,push=false"
]
},
"backend-management": {
"context": "services/",
"dockerfile": "backend/Dockerfile",

View File

@ -1,16 +1,17 @@
services:
postgres:
ports: []
ports: !override []
keycloak:
command: "echo 'NOOP'"
ports: []
ports: !override []
restart: "no"
rabbitmq:
ports: []
ports: !override []
include:
- path: "./services/backend/docker-compose-ci.yaml"
- path: "./services/packages/docker-compose-ci.yaml"
- path: "./services/extension/docker-compose-ci.yaml"
- path: "./services/apple/docker-compose-ci.yaml"

View File

@ -6,6 +6,7 @@ include:
- path: "./services/backend/docker-compose.yaml"
- path: "./services/packages/docker-compose.yaml"
- path: "./services/extension/docker-compose.yaml"
- path: "./services/apple/docker-compose.yaml"
volumes: {}

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "hotpocket-workspace"
version = "25.10.4"
version = "25.10.13"
description = "HotPocket Workspace"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0"

View File

@ -1,5 +1,8 @@
.mypy_cache/
.pytest_cache/
_tmp/
apple/
apple/build/
apple/DerivedData/
backend/node_modules/
backend/ops/metal/
backend/hotpocket_backend/playground.py

19
services/apple/Dockerfile Normal file
View File

@ -0,0 +1,19 @@
ARG APP_USER_UID=1000
ARG APP_USER_GID=1000
ARG IMAGE_ID=development.00000000
FROM docker-hosted.nexus.bthlabs.pl/hotpocket/base:build-node-20250819-01 AS development
ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
# COPY --chown=$APP_USER_UID:$APP_USER_GID apple/ops/bin/*.sh /srv/bin/
VOLUME ["/srv/node_modules", "/srv/venv"]
FROM development AS ci
COPY --chown=$APP_USER_UID:$APP_USER_GID apple/ /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/packages/
COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/

View File

@ -713,7 +713,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "iOS (Share Extension)/iOS (Share Extension).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (Share Extension)/Info.plist";
@ -726,7 +726,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension;
PRODUCT_NAME = "Save to HotPocket";
SDKROOT = iphoneos;
@ -746,7 +746,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = "iOS (Share Extension)/iOS (Share Extension).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (Share Extension)/Info.plist";
@ -759,7 +759,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension;
PRODUCT_NAME = "Save to HotPocket";
SDKROOT = iphoneos;
@ -779,7 +779,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (Extension)/Info.plist";
@ -792,7 +792,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@ -814,7 +814,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (Extension)/Info.plist";
@ -827,7 +827,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@ -853,7 +853,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "iOS (App)/HotPocket (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (App)/Info.plist";
@ -873,7 +873,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@ -899,7 +899,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "iOS (App)/HotPocket (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "iOS (App)/Info.plist";
@ -919,7 +919,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@ -945,7 +945,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -960,7 +960,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@ -980,7 +980,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@ -995,7 +995,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@ -1017,7 +1017,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (App)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1033,7 +1033,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@ -1056,7 +1056,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (App)/HotPocket.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1072,7 +1072,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
@ -1206,7 +1206,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (Share Extension)/macOS (Share Extension).entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1220,7 +1220,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension;
PRODUCT_NAME = "Save to HotPocket";
REGISTER_APP_GROUPS = YES;
@ -1236,7 +1236,7 @@
CODE_SIGN_ENTITLEMENTS = "macOS (Share Extension)/macOS (Share Extension).entitlements";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2025100401;
CURRENT_PROJECT_VERSION = 2025101302;
DEVELOPMENT_TEAM = 648728X64K;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -1250,7 +1250,7 @@
"@executable_path/../../../../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 25.10.4;
MARKETING_VERSION = 25.10.13;
PRODUCT_BUNDLE_IDENTIFIER = pl.bthlabs.HotPocket.ShareExtension;
PRODUCT_NAME = "Save to HotPocket";
REGISTER_APP_GROUPS = YES;

View File

@ -0,0 +1,23 @@
services:
apple-ci:
build:
context: ".."
dockerfile: "apple/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:ci-local"
command: "echo 'NOOP'"
environment:
PYTHONBREAKPOINT: "ipdb.set_trace"
HOTPOCKET_PACKAGES_ENV: "${HOTPOCKET_EXTENSION_ENV:-docker}"
# REQUESTS_CA_BUNDLE: "/srv/tls/requests_ca_bundle.pem"
RUN_POETRY_INSTALL: "true"
RUN_YARN_INSTALL: "false"
SETUP_BACKEND: "true"
SETUP_FRONTEND: "false"
volumes:
- "apple_venv:/srv/venv"
- "apple_node_modules:/srv/node_modules"
- "../tls:/srv/tls"
restart: "no"
stdin_open: true
tty: true

View File

@ -0,0 +1,29 @@
services:
apple-management:
build:
context: ".."
dockerfile: "apple/Dockerfile"
target: "development"
image: "docker-hosted.nexus.bthlabs.pl/hotpocket/apple:local"
command: "echo 'NOOP'"
environment: &apple-env
PYTHONBREAKPOINT: "ipdb.set_trace"
HOTPOCKET_EXTENSION_ENV: "${HOTPOCKET_EXTENSION_ENV:-docker}"
REQUESTS_CA_BUNDLE: "/srv/tls/requests_ca_bundle.pem"
RUN_POETRY_INSTALL: "true"
RUN_YARN_INSTALL: "false"
SETUP_BACKEND: "true"
SETUP_FRONTEND: "false"
volumes:
- "apple_venv:/srv/venv"
- "apple_node_modules:/srv/node_modules"
- ".:/srv/app"
- "../packages:/srv/packages"
- "../tls:/srv/tls"
restart: "no"
stdin_open: true
tty: true
volumes:
apple_venv:
apple_node_modules:

View File

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "hotpocket-apple"
version = "25.10.4"
version = "25.10.13"
description = "HotPocket Apple Integrations"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0"

View File

@ -8,12 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
USER root
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/bin/*.sh /srv/bin/
RUN chown -R ${APP_USER_UID}:${APP_USER_GID} /srv
USER app
VOLUME ["/srv/node_modules", "/srv/venv"]
@ -50,7 +45,6 @@ COPY --from=deployment-build /srv/packages /srv/packages
COPY --from=deployment-build /srv/venv /srv/venv
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/bin/*.sh /srv/bin/
COPY --chown=$APP_USER_UID:$APP_USER_GID backend/ops/deployment/gunicorn.conf.py backend/ops/deployment/gunicorn.logging.conf /srv/lib/
RUN chown -R $APP_USER_UID:$APP_USER_GID /srv
USER root
@ -109,5 +103,4 @@ COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/packages/
COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/
RUN ln -s /srv/app/ops/docker/settings /srv/app/hotpocket_backend/settings/docker && \
ln -s /srv/app/ops/docker/secrets /srv/app/hotpocket_backend/secrets/docker && \
chown -R $APP_USER_UID:$APP_USER_GID /srv
ln -s /srv/app/ops/docker/secrets /srv/app/hotpocket_backend/secrets/docker

View File

@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
version = '25.10.4'
version = '25.10.13'

View File

@ -68,4 +68,10 @@ class Account(AbstractUser):
else:
result['auto_load_embeds'] = auto_load_embeds
light_mode = result.get('light_mode', None)
if isinstance(light_mode, str) is True:
result['light_mode'] = (light_mode == 'True')
else:
result['light_mode'] = light_mode
return result

View File

@ -6,6 +6,7 @@ import hmac
import logging
import uuid
from django.core.exceptions import ValidationError
from django.db import models
import uuid6
@ -15,6 +16,10 @@ from hotpocket_soa.dto.accounts import (
AccessTokenMetaUpdateIn,
AccessTokensQuery,
)
from hotpocket_soa.exceptions.backend import (
Invalid as InvalidError,
NotFound as NotFoundError,
)
LOGGER = logging.getLogger(__name__)
@ -23,7 +28,10 @@ class AccessTokensService:
class AccessTokensServiceError(Exception):
pass
class AccessTokenNotFound(AccessTokensServiceError):
class Invalid(InvalidError, AccessTokensServiceError):
pass
class NotFound(NotFoundError, AccessTokensServiceError):
pass
def create(self,
@ -32,6 +40,7 @@ class AccessTokensService:
origin: str,
meta: dict,
) -> AccessToken:
try:
pk = uuid6.uuid7()
key = hmac.new(
settings.SECRET_KEY.encode('ascii'),
@ -46,6 +55,8 @@ class AccessTokensService:
origin=origin,
meta=meta,
)
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, *, pk: uuid.UUID) -> AccessToken:
try:
@ -53,7 +64,7 @@ class AccessTokensService:
return query_set.get(pk=pk)
except AccessToken.DoesNotExist as exception:
raise self.AccessTokenNotFound(
raise self.NotFound(
f'Access Token not found: pk=`{pk}`',
) from exception
@ -63,7 +74,7 @@ class AccessTokensService:
return query_set.get(key=key)
except AccessToken.DoesNotExist as exception:
raise self.AccessTokenNotFound(
raise self.NotFound(
f'Access Token not found: key=`{key}`',
) from exception
@ -98,7 +109,7 @@ class AccessTokensService:
pk: uuid.UUID,
update: AccessTokenMetaUpdateIn,
) -> AccessToken:
access_token = AccessToken.active_objects.get(pk=pk)
access_token = self.get(pk=pk)
next_meta = {
**(access_token.meta or {}),

View File

@ -5,6 +5,7 @@ import logging
import uuid
from hotpocket_backend.apps.accounts.models import Account
from hotpocket_soa.exceptions.backend import NotFound as NotFoundError
LOGGER = logging.getLogger(__name__)
@ -13,7 +14,7 @@ class AccountsService:
class AccountsServiceError(Exception):
pass
class AccountNotFound(AccountsServiceError):
class NotFound(NotFoundError, AccountsServiceError):
pass
def get(self, *, pk: uuid.UUID) -> Account:
@ -22,6 +23,6 @@ class AccountsService:
return query_set.get(pk=pk)
except Account.DoesNotExist as exception:
raise self.AccountNotFound(
raise self.NotFound(
f'Account not found: pk=`{pk}`',
) from exception

View File

@ -5,11 +5,17 @@ import datetime
import logging
import uuid
from django.core.exceptions import ValidationError
from django.utils.timezone import now
import uuid6
from hotpocket_backend.apps.accounts.models import AuthKey
from hotpocket_backend.apps.core.conf import settings
from hotpocket_soa.exceptions.backend import (
InternalError,
Invalid as InvalidError,
NotFound as NotFoundError,
)
LOGGER = logging.getLogger(__name__)
@ -18,22 +24,25 @@ class AuthKeysService:
class AuthKeysServiceError(Exception):
pass
class AuthKeyNotFound(AuthKeysServiceError):
class Invalid(InvalidError, AuthKeysServiceError):
pass
class AuthKeyExpired(AuthKeysServiceError):
class NotFound(NotFoundError, AuthKeysServiceError):
pass
class AuthKeyAccessDenied(AuthKeysServiceError):
class Expired(InternalError, AuthKeysServiceError):
pass
def create(self, *, account_uuid: uuid.UUID) -> AuthKey:
try:
key = str(uuid6.uuid7())
return AuthKey.objects.create(
account_uuid=account_uuid,
key=key,
)
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, *, pk: uuid.UUID) -> AuthKey:
try:
@ -41,7 +50,7 @@ class AuthKeysService:
return query_set.get(pk=pk)
except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound(
raise self.NotFound(
f'Auth Key not found: pk=`{pk}`',
) from exception
@ -56,17 +65,17 @@ class AuthKeysService:
if ttl > 0:
if result.created_at < now() - datetime.timedelta(seconds=ttl):
raise self.AuthKeyExpired(
raise self.Expired(
f'Auth Key expired: pk=`{key}`',
)
if result.consumed_at is not None:
raise self.AuthKeyExpired(
raise self.Expired(
f'Auth Key already consumed: pk=`{key}`',
)
return result
except AuthKey.DoesNotExist as exception:
raise self.AuthKeyNotFound(
raise self.NotFound(
f'Auth Key not found: key=`{key}`',
) from exception

View File

@ -1,16 +1,39 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import functools
import typing
from bthlabs_jsonrpc_core.exceptions import BaseJSONRPCError
from bthlabs_jsonrpc_django import (
DjangoExecutor,
DjangoJSONRPCSerializer,
JSONRPCView as BaseJSONRPCView,
)
from django.core.exceptions import ValidationError
import uuid6
from hotpocket_soa.exceptions.frontend import SOAError
class SOAJSONRPCError(BaseJSONRPCError):
ERROR_CODE = -32000
ERROR_MESSAGE = 'SOA Error'
def to_rpc(self) -> dict:
exception = typing.cast(SOAError, self.data)
code = (
exception.code
if exception.code is not None
else self.ERROR_CODE
)
return {
'code': code,
'message': exception.message or self.ERROR_MESSAGE,
'data': exception.data,
}
class JSONRPCSerializer(DjangoJSONRPCSerializer):
STRING_COERCIBLE_TYPES: typing.Any = (
@ -18,30 +41,6 @@ class JSONRPCSerializer(DjangoJSONRPCSerializer):
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
@ -49,3 +48,14 @@ class Executor(DjangoExecutor):
class JSONRPCView(BaseJSONRPCView):
executor = Executor
def wrap_soa_errors(func: typing.Callable) -> typing.Callable:
@functools.wraps(func)
def decorator(*args, **kwargs):
try:
return func(*args, **kwargs)
except SOAError as exception:
raise SOAJSONRPCError(exception)
return decorator

View File

@ -5,6 +5,7 @@ import datetime
import logging
import uuid
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.timezone import now
@ -15,6 +16,10 @@ from hotpocket_soa.dto.associations import (
AssociationsQuery,
AssociationUpdateIn,
)
from hotpocket_soa.exceptions.backend import (
Invalid as InvalidError,
NotFound as NotFoundError,
)
from .saves import SavesService
@ -25,7 +30,10 @@ class AssociationsService:
class AssociationsServiceError(Exception):
pass
class AssociationNotFound(AssociationsServiceError):
class Invalid(InvalidError, AssociationsServiceError):
pass
class NotFound(NotFoundError, AssociationsServiceError):
pass
@property
@ -46,6 +54,7 @@ class AssociationsService:
pk: uuid.UUID | None = None,
created_at: datetime.datetime | None = None,
) -> Association:
try:
save = SavesService().get(pk=save_uuid)
defaults = dict(
@ -70,6 +79,8 @@ class AssociationsService:
result.save()
return result
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self,
*,
@ -87,7 +98,7 @@ class AssociationsService:
return query_set.get(pk=pk)
except Association.DoesNotExist as exception:
raise self.AssociationNotFound(
raise self.NotFound(
f'Association not found: pk=`{pk}`',
) from exception
@ -112,6 +123,7 @@ class AssociationsService:
pk: uuid.UUID,
update: AssociationUpdateIn,
) -> Association:
try:
association = self.get(pk=pk)
association.target_title = update.target_title
association.target_description = update.target_description
@ -127,6 +139,8 @@ class AssociationsService:
association.save()
return association
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def archive(self, *, pk: uuid.UUID) -> bool:
association = self.get(pk=pk)

View File

@ -5,19 +5,27 @@ import hashlib
import typing
import uuid
from django.core.exceptions import ValidationError
from django.db import models
from hotpocket_backend.apps.core.services import get_adapter
from hotpocket_backend.apps.saves.models import Save
from hotpocket_backend.apps.saves.types import PSaveAdapter
from hotpocket_soa.dto.saves import ImportedSaveIn, SaveIn, SavesQuery
from hotpocket_soa.exceptions.backend import (
Invalid as InvalidError,
NotFound as NotFoundError,
)
class SavesService:
class SavesServiceError(Exception):
pass
class SaveNotFound(SavesServiceError):
class Invalid(InvalidError, SavesServiceError):
pass
class NotFound(NotFoundError, SavesServiceError):
pass
@property
@ -36,6 +44,7 @@ class SavesService:
account_uuid: uuid.UUID,
save: SaveIn | ImportedSaveIn,
) -> Save:
try:
key = hashlib.sha256(save.url.encode('utf-8')).hexdigest()
defaults = dict(
@ -59,12 +68,14 @@ class SavesService:
save_object.save()
return save_object
except ValidationError as exception:
raise self.Invalid.from_django_validation_error(exception)
def get(self, *, pk: uuid.UUID) -> Save:
try:
return Save.active_objects.get(pk=pk)
except Save.DoesNotExist as exception:
raise self.SaveNotFound(
raise self.NotFound(
f'Save not found: pk=`{pk}`',
) from exception

View File

@ -46,3 +46,33 @@ def version(request: HttpRequest) -> dict:
return {
'VERSION': backend_version,
}
def appearance_settings(request: HttpRequest) -> dict:
theme = 'hotpocket'
result = {
'theme': theme,
'light_mode': False,
'theme_css': 'ui/css/bootstrap-hotpocket.min.css',
}
if request.user.is_anonymous is False:
theme = request.user.settings.get('theme', 'hotpocket')
result.update({
'theme': theme,
'light_mode': request.user.settings['light_mode'],
})
match theme:
case 'bootstrap':
result['theme_css'] = 'ui/css/bootstrap.min.css'
case 'cosmo':
result['theme_css'] = 'ui/css/cosmo.min.css'
case 'solar':
result['theme_css'] = 'ui/css/solar.min.css'
return {
'APPEARANCE_SETTINGS': result,
}

View File

@ -143,13 +143,18 @@ class SettingsForm(Form):
theme = forms.ChoiceField(
label=_('Theme'),
disabled=True,
disabled=False,
required=False,
choices=[
(None, _('Bootstrap')),
(None, _('BTHLabs')),
('bootstrap', _('Bootstrap')),
('cosmo', _('Cosmo')),
('solar', _('Solar')),
],
show_hidden_initial=True,
)
light_mode = forms.BooleanField(
label=_('Use light mode'),
required=False,
)
auto_load_embeds = forms.ChoiceField(
label=_('Auto load embedded content'),
@ -168,6 +173,7 @@ class SettingsForm(Form):
def get_layout_fields(self) -> list[str]:
return [
'theme',
'light_mode',
'auto_load_embeds',
]

View File

@ -7,6 +7,7 @@ from bthlabs_jsonrpc_core import register_method
from django import db
from django.http import HttpRequest
from hotpocket_backend.apps.core.rpc import wrap_soa_errors
from hotpocket_soa.services import (
AccessTokensService,
AccountsService,
@ -17,6 +18,7 @@ LOGGER = logging.getLogger(__name__)
@register_method('accounts.access_tokens.create', namespace='accounts')
@wrap_soa_errors
def create(request: HttpRequest,
auth_key: str,
meta: dict,
@ -27,7 +29,7 @@ def create(request: HttpRequest,
account_uuid=None,
key=auth_key,
)
except AuthKeysService.AuthKeyNotFound as exception:
except AuthKeysService.NotFound as exception:
LOGGER.error(
'Unable to issue access token: %s',
exception,
@ -37,7 +39,7 @@ def create(request: HttpRequest,
try:
account = AccountsService().get(pk=auth_key_object.account_uuid)
except AccountsService.AccountNotFound as exception:
except AccountsService.NotFound as exception:
LOGGER.error(
'Unable to issue access token: %s',
exception,

View File

@ -44,7 +44,7 @@ def check_access_token(request: HttpRequest,
access_token=access_token_object,
update=meta_update,
)
except AccessTokensService.AccessTokenNotFound as exception:
except AccessTokensService.NotFound as exception:
LOGGER.error(
'Access Token not found: account_uuid=`%s` key=`%s`',
request.user.pk,
@ -52,7 +52,7 @@ def check_access_token(request: HttpRequest,
exc_info=exception,
)
result = False
except AccessTokensService.AccessTokenAccessDenied as exception:
except AccessTokensService.AccessDenied as exception:
LOGGER.error(
'Access Token access denied: account_uuid=`%s` key=`%s`',
request.user.pk,

View File

@ -4,11 +4,13 @@ from __future__ import annotations
from bthlabs_jsonrpc_core import register_method
from django.http import HttpRequest
from hotpocket_backend.apps.core.rpc import wrap_soa_errors
from hotpocket_backend.apps.ui.services.workflows import CreateSaveWorkflow
from hotpocket_soa.dto.associations import AssociationOut
@register_method(method='saves.create')
@wrap_soa_errors
def create(request: HttpRequest, url: str) -> AssociationOut:
association = CreateSaveWorkflow().run_rpc(
request=request,

View File

@ -27,7 +27,7 @@ class UIAccessTokensService:
account_uuid=account_uuid,
pk=pk,
)
except AccessTokensService.AccessTokenNotFound as exception:
except AccessTokensService.NotFound as exception:
LOGGER.error(
'Access Token not found: account_uuid=`%s` pk=`%s`',
account_uuid,
@ -35,7 +35,7 @@ class UIAccessTokensService:
exc_info=exception,
)
raise Http404()
except AccessTokensService.AccessTokenAccessDenied as exception:
except AccessTokensService.AccessDenied as exception:
LOGGER.error(
'Access Token access denied: account_uuid=`%s` pk=`%s`',
account_uuid,

View File

@ -34,7 +34,7 @@ class UIAssociationsService:
with_target=True,
allow_archived=allow_archived,
)
except AssociationsService.AssociationNotFound as exception:
except AssociationsService.NotFound as exception:
LOGGER.error(
'Association not found: account_uuid=`%s` pk=`%s`',
account_uuid,
@ -42,7 +42,7 @@ class UIAssociationsService:
exc_info=exception,
)
raise Http404()
except AssociationsService.AssociationAccessDenied as exception:
except AssociationsService.AccessDenied as exception:
LOGGER.error(
'Association access denied: account_uuid=`%s` pk=`%s`',
account_uuid,

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import csv
import datetime
import logging
import os
import uuid
@ -13,16 +14,28 @@ from django.utils.timezone import get_current_timezone
from hotpocket_backend.apps.ui.services.workflows import ImportSaveWorkflow
from hotpocket_backend.apps.ui.tasks import import_from_pocket
from hotpocket_common.uuid import uuid7_from_timestamp
from hotpocket_soa.services import SavesService
LOGGER = logging.getLogger(__name__)
class UIImportsService:
def import_from_pocket(self,
*,
job: str,
account_uuid: uuid.UUID,
csv_path: str,
) -> list[tuple[uuid.UUID, uuid.UUID]]:
result = []
LOGGER.info(
'Starting import job: job=`%s` account_uuid=`%s`',
job,
account_uuid,
extra={
'job': job,
},
)
result = []
with db.transaction.atomic():
try:
with open(csv_path, 'r', encoding='utf-8') as csv_file:
@ -34,13 +47,14 @@ class UIImportsService:
current_timezone = get_current_timezone()
is_header = False
for row in csv_reader:
for row_number, row in enumerate(csv_reader, start=1):
if is_header is False:
is_header = True
continue
timestamp = int(row['time_added'])
try:
save, association = ImportSaveWorkflow().run(
account_uuid=account_uuid,
url=row['url'],
@ -50,6 +64,18 @@ class UIImportsService:
timestamp, tz=current_timezone,
),
)
except SavesService.Invalid as exception:
LOGGER.error(
'Import error: row_number=`%d` url=`%s` exception=`%s`',
row_number,
row['url'],
exception,
exc_info=exception,
extra={
'job': job,
},
)
continue
result.append((save.pk, association.pk))
finally:
@ -64,6 +90,7 @@ class UIImportsService:
) -> AsyncResult:
return import_from_pocket.apply_async(
kwargs={
'job': str(uuid.uuid4()),
'account_uuid': account_uuid,
'csv_path': csv_path,
},

View File

@ -19,7 +19,7 @@ class UISavesService:
def get_or_404(self, *, pk: uuid.UUID) -> SaveOut:
try:
return SavesService().get(pk=pk)
except SavesService.SaveNotFound as exception:
except SavesService.NotFound as exception:
LOGGER.error(
'Save not found: pk=`%s`', pk, exc_info=exception,
)

View File

@ -1,9 +1,7 @@
# -*- 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
@ -14,7 +12,6 @@ from hotpocket_backend.apps.accounts.types import PAccount
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
@ -73,14 +70,8 @@ class CreateSaveWorkflow(SaveWorkflow):
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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -11,6 +11,7 @@ LOGGER = logging.getLogger(__name__)
@shared_task
def import_from_pocket(*,
job: str,
account_uuid: uuid.UUID,
csv_path: str,
) -> list[tuple[uuid.UUID, uuid.UUID]]:
@ -18,6 +19,7 @@ def import_from_pocket(*,
try:
return UIImportsService().import_from_pocket(
job=job,
account_uuid=account_uuid,
csv_path=csv_path,
)

View File

@ -10,14 +10,18 @@
|_|
production
-->
<html lang="en" data-bs-theme="dark">
<html lang="en" data-bs-theme="{% if APPEARANCE_SETTINGS.light_mode %}light{% else %}dark{% endif %}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="generator" content="pl.bthlabs.HotPocket.backend@{{ IMAGE_ID }}">
{% if APPEARANCE_SETTINGS.light_mode %}
<meta name="theme-color" content="#f8f9fa" />
{% else %}
<meta name="theme-color" content="#2b3035" />
{% endif %}
<title>{% block title %}{% translate 'Not Found' %}{% endblock %} | {{ SITE_TITLE }}</title>
<link href="{% static 'ui/css/bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static APPEARANCE_SETTINGS.theme_css %}" rel="stylesheet">
<link href="{% static 'ui/css/bootstrap-icons.min.css' %}" rel="stylesheet">
<link href="{% static 'ui/css/hotpocket-backend.css' %}" rel="stylesheet">
<meta name="apple-mobile-web-app-capable" content="yes">

View File

@ -9,6 +9,10 @@ from hotpocket_backend.apps.core.conf import settings
def manifest_json(request: HttpRequest) -> JsonResponse:
light_theme = False
if request.user.is_anonymous is False:
light_theme = request.user.settings.get('light_theme', None) or False
result = {
'name': settings.SITE_TITLE,
'short_name': settings.SITE_SHORT_TITLE,
@ -16,8 +20,17 @@ def manifest_json(request: HttpRequest) -> JsonResponse:
reverse('ui.associations.browse'),
),
'display': 'standalone',
'background_color': '#212529',
'theme_color': '#2b3035',
'background_color': (
'#212529'
if light_theme is False
else '#ffffff'
),
'theme_color': (
'#2b3035'
if light_theme is False
else
'#f8f9fa'
),
'icons': [
{
'src': request.build_absolute_uri(

View File

@ -67,6 +67,7 @@ TEMPLATES = [
'hotpocket_backend.apps.ui.context_processors.htmx',
'hotpocket_backend.apps.ui.context_processors.debug',
'hotpocket_backend.apps.ui.context_processors.version',
'hotpocket_backend.apps.ui.context_processors.appearance_settings',
],
},
},

View File

@ -13,7 +13,7 @@ cat <<EOF
|_|
production
HotPocket v25.10.4 [${HOTPOCKET_BACKEND_IMAGE_ID}] (https://hotpocket.app/)
HotPocket v25.10.13 [${HOTPOCKET_BACKEND_IMAGE_ID}] (https://hotpocket.app/)
Copyright 2025-present by BTHLabs. All rights reserved. (https://bthlabs.pl/)
Licensed under Apache-2.0
EOF

View File

@ -1,6 +1,6 @@
{
"name": "hotpocket-backend",
"version": "25.10.4",
"version": "25.10.13",
"description": "HotPocket Backend",
"main": "hotpocket_backend/apps/frontend/src/index.js",
"repository": "https://git.bthlabs.pl/tomekwojcik/hotpocket",

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "hotpocket-backend"
version = "25.10.4"
version = "25.10.13"
description = "HotPocket Backend"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0"

View File

@ -76,6 +76,18 @@ def starred_association_out(starred_association):
)
@pytest.fixture
def deleted_save_association(association_factory, deleted_save):
return association_factory(target=deleted_save)
@pytest.fixture
def deleted_save_association_out(deleted_save_association):
return AssociationWithTargetOut.model_validate(
deleted_save_association, from_attributes=True,
)
@pytest.fixture
def other_account_association(association_factory, other_account):
return association_factory(account=other_account)
@ -124,6 +136,18 @@ def other_account_starred_association_out(other_account_starred_association):
)
@pytest.fixture
def other_account_deleted_save_association(association_factory, other_account, deleted_save):
return association_factory(account=other_account, target=deleted_save)
@pytest.fixture
def other_account_deleted_save_association_out(other_account_deleted_save_association):
return AssociationWithTargetOut.model_validate(
other_account_deleted_save_association, from_attributes=True,
)
@pytest.fixture
def browsable_associations(association,
deleted_association,

View File

@ -64,11 +64,25 @@ def pocket_import_banned_netloc_save_spec():
})
@pytest.fixture
def pocket_import_invalid_url_spec():
return PocketImportSaveSpec.model_validate({
'title': "This isn't right",
'url': 'thisisntright',
'time_added': datetime.datetime(
2021, 1, 18, 8, 0, 0, 0, tzinfo=datetime.UTC,
),
'tags': '',
'status': 'unread',
})
@pytest.fixture
def pocket_csv_content(pocket_import_created_save_spec,
pocket_import_reused_save_spec,
pocket_import_other_account_save_spec,
pocket_import_banned_netloc_save_spec,
pocket_import_invalid_url_spec,
):
with io.StringIO() as csv_f:
field_names = [
@ -82,6 +96,7 @@ def pocket_csv_content(pocket_import_created_save_spec,
pocket_import_reused_save_spec.dict(),
pocket_import_other_account_save_spec.dict(),
pocket_import_banned_netloc_save_spec.dict(),
pocket_import_invalid_url_spec.dict(),
])
csv_f.seek(0)

View File

@ -29,6 +29,20 @@ class AssociationsTestingService:
if reference is not None:
assert association.updated_at > reference.updated_at
def assert_deleted(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = Association.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 = Association.objects.get(pk=pk)
assert association.deleted_at is None
if reference is not None:
assert association.updated_at == reference.updated_at
def assert_starred(self, *, pk: uuid.UUID, reference: typing.Any = None):
association = Association.objects.get(pk=pk)
assert association.starred_at is not None

View File

@ -45,10 +45,12 @@ def test_ok(account,
other_account_save_out,
pocket_import_other_account_save_spec: PocketImportSaveSpec,
pocket_import_banned_netloc_save_spec: PocketImportSaveSpec,
pocket_import_invalid_url_spec: PocketImportSaveSpec,
mock_saves_process_save_task_apply_async: mock.Mock,
):
# When
result = tasks_module.import_from_pocket(
job='test',
account_uuid=account.pk,
csv_path=str(pocket_csv_file_path),
)

View File

@ -16,6 +16,7 @@ from hotpocket_backend_testing.services.accounts import AccountsTestingService
def payload():
return {
'theme': 'cosmo',
'light_mode': True,
'auto_load_embeds': 'True',
}
@ -41,7 +42,8 @@ def test_ok(authenticated_client: Client,
AccountsTestingService().assert_settings_edited(
pk=account.pk,
update={
'theme': None, # TODO: Themes!
'theme': 'cosmo',
'light_mode': True,
'auto_load_embeds': True,
},
reference=account,

View File

@ -100,6 +100,54 @@ def test_invalid_all_empty(authenticated_client: Client,
assert 'canhazconfirm' in result.context['form'].errors
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.archive', args=(archived_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.archive', args=(deleted_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.post(
reverse('ui.associations.archive', args=(null_uuid,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_other_account_association(authenticated_client: Client,
other_account_association_out,

View File

@ -9,7 +9,7 @@ from django.urls import reverse
import pytest
from pytest_django import asserts
from hotpocket_backend.apps.saves.models import Association
from hotpocket_backend_testing.services.saves import AssociationsTestingService
from hotpocket_common.constants import AssociationsSearchMode
@ -35,9 +35,10 @@ def test_ok(authenticated_client: Client,
fetch_redirect_response=False,
)
association_object = Association.objects.get(pk=association_out.pk)
assert association_object.updated_at > association_out.updated_at
assert association_object.deleted_at is not None
AssociationsTestingService().assert_deleted(
pk=association_out.pk,
reference=association_out,
)
@pytest.mark.django_db
@ -65,6 +66,34 @@ def test_ok_htmx(authenticated_client: Client,
assert result.json() == expected_payload
@pytest.mark.django_db
def test_ok_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.delete', args=(archived_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
asserts.assertRedirects(
result,
reverse(
'ui.associations.browse',
query=[('mode', AssociationsSearchMode.ARCHIVED.value)],
),
fetch_redirect_response=False,
)
AssociationsTestingService().assert_deleted(
pk=archived_association_out.pk,
reference=archived_association_out,
)
@pytest.mark.django_db
def test_invalid_all_missing(authenticated_client: Client,
association_out,
@ -78,13 +107,13 @@ def test_invalid_all_missing(authenticated_client: Client,
# Then
assert result.status_code == http.HTTPStatus.OK
association_object = Association.objects.get(pk=association_out.pk)
assert association_object.updated_at == association_out.updated_at
assert association_object.deleted_at is None
assert 'canhazconfirm' in result.context['form'].errors
AssociationsTestingService().assert_not_deleted(
pk=association_out.pk,
reference=association_out,
)
@pytest.mark.django_db
def test_invalid_all_empty(authenticated_client: Client,
@ -100,13 +129,45 @@ def test_invalid_all_empty(authenticated_client: Client,
# Then
assert result.status_code == http.HTTPStatus.OK
association_object = Association.objects.get(pk=association_out.pk)
assert association_object.updated_at == association_out.updated_at
assert association_object.deleted_at is None
assert 'canhazconfirm' in result.context['form'].errors
AssociationsTestingService().assert_not_deleted(
pk=association_out.pk,
reference=association_out,
)
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.delete', args=(deleted_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.post(
reverse('ui.associations.delete', args=(null_uuid,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_other_account_association(authenticated_client: Client,

View File

@ -47,7 +47,7 @@ def test_ok(authenticated_client: Client,
@pytest.mark.django_db
def test_invalid_all_empty(authenticated_client: Client,
def test_all_empty(authenticated_client: Client,
association_out,
payload,
):
@ -79,7 +79,7 @@ def test_invalid_all_empty(authenticated_client: Client,
@pytest.mark.django_db
def test_invalid_all_missing(authenticated_client: Client,
def test_all_missing(authenticated_client: Client,
association_out,
):
# Given
@ -105,6 +105,45 @@ def test_invalid_all_missing(authenticated_client: Client,
)
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.edit', args=(archived_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.edit', args=(deleted_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.post(
reverse('ui.associations.edit', args=(null_uuid,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_other_account_association(authenticated_client: Client,
other_account_association_out,

View File

@ -128,6 +128,54 @@ def test_invalid_all_empty(authenticated_client: Client,
assert 'canhazconfirm' in result.context['form'].errors
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.refresh', args=(archived_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.post(
reverse('ui.associations.refresh', args=(deleted_association_out.pk,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.post(
reverse('ui.associations.refresh', args=(null_uuid,)),
data={
'canhazconfirm': 'hai',
},
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_other_account_association(authenticated_client: Client,
other_account_association_out,

View File

@ -54,6 +54,45 @@ def test_ok_htmx(authenticated_client: Client,
assert result.context['association'].target.pk == association_out.target.pk
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.star', args=(archived_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.star', args=(deleted_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.get(
reverse('ui.associations.star', args=(null_uuid,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_other_account_association(authenticated_client: Client,
other_account_association_out,

View File

@ -54,6 +54,45 @@ def test_ok_htmx(authenticated_client: Client,
assert result.context['association'].target.pk == starred_association_out.target.pk
@pytest.mark.django_db
def test_archived(authenticated_client: Client,
archived_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.unstar', args=(archived_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_deleted(authenticated_client: Client,
deleted_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.unstar', args=(deleted_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_not_found(authenticated_client: Client,
null_uuid,
):
# When
result = authenticated_client.get(
reverse('ui.associations.unstar', args=(null_uuid,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_other_account_association(authenticated_client: Client,
other_account_starred_association_out,

View File

@ -66,6 +66,19 @@ def test_authenticated_deleted(authenticated_client: Client,
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_authenticated_deleted_save(authenticated_client: Client,
deleted_save_association_out,
):
# When
result = authenticated_client.get(
reverse('ui.associations.view', args=(deleted_save_association_out.pk,)),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_authenticated_not_found(authenticated_client: Client,
null_uuid,
@ -169,6 +182,23 @@ def test_authenticated_share_deleted(authenticated_client: Client,
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_authenticated_share_deleted_save(authenticated_client: Client,
other_account_deleted_save_association_out,
):
# When
result = authenticated_client.get(
reverse(
'ui.associations.view',
args=(other_account_deleted_save_association_out.pk,),
query=[('share', 'true')],
),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_authenticated_share_not_found(authenticated_client: Client,
null_uuid,
@ -240,6 +270,23 @@ def test_anonymous_share_deleted(client: Client,
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_anonymous_share_deleted_save(client: Client,
deleted_save_association_out,
):
# When
result = client.get(
reverse(
'ui.associations.view',
args=(deleted_save_association_out.pk,),
query=[('share', 'true')],
),
)
# Then
assert result.status_code == http.HTTPStatus.NOT_FOUND
@pytest.mark.django_db
def test_anonymous_share_not_found(client: Client,
null_uuid,

View File

@ -76,6 +76,7 @@ def test_ok(override_settings_upload_path,
mock_ui_import_from_pocket_task_apply_async.assert_called_once_with(
kwargs={
'job': mock.ANY,
'account_uuid': account.pk,
'csv_path': str(uploaded_file_path),
},

View File

@ -77,10 +77,11 @@ def test_auth_key_not_found(null_uuid,
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'].startswith(
assert call_result['error']['code'] == -32001
assert call_result['error']['message'].startswith(
'Auth Key not found',
)
assert call_auth_key in call_result['error']['data']
assert call_auth_key in call_result['error']['message']
@pytest.mark.django_db
@ -108,10 +109,11 @@ def test_deleted_auth_key(deleted_auth_key_out,
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'].startswith(
assert call_result['error']['code'] == -32001
assert call_result['error']['message'].startswith(
'Auth Key not found',
)
assert call_auth_key in call_result['error']['data']
assert call_auth_key in call_result['error']['message']
@pytest.mark.django_db
@ -139,10 +141,11 @@ def test_expired_auth_key(expired_auth_key_out,
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'].startswith(
assert call_result['error']['code'] == -32000
assert call_result['error']['message'].startswith(
'Auth Key expired',
)
assert call_auth_key in call_result['error']['data']
assert call_auth_key in call_result['error']['message']
@pytest.mark.django_db
@ -170,10 +173,11 @@ def test_consumed_auth_key(consumed_auth_key,
call_result = result.json()
assert 'error' in call_result
assert call_result['error']['data'].startswith(
assert call_result['error']['code'] == -32000
assert call_result['error']['message'].startswith(
'Auth Key already consumed',
)
assert call_auth_key in call_result['error']['data']
assert call_auth_key in call_result['error']['message']
@pytest.mark.django_db
@ -201,4 +205,5 @@ def test_inactive_account(inactive_account_auth_key,
call_result = result.json()
assert 'error' in call_result
assert str(inactive_account.pk) in call_result['error']['data']
assert call_result['error']['code'] == -32001
assert str(inactive_account.pk) in call_result['error']['message']

View File

@ -1,3 +0,0 @@
run:
echo: true
pty: true

View File

@ -1,14 +0,0 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
from invoke import task
@task
def publish(ctx):
ctx.run((
'rsync '
'-rv '
'webroot/ '
'snakeweb.net:/srv/sites/bilbo/hotpocket.app/dotcom/'
))

View File

@ -1,26 +0,0 @@
BTHLabsHotPocketBot
===================
BTHLabsHotPocketBot is the metadata bot software that BTHLabs HotPocket uses to
discover and collect metadata of links saved by its users.
BTHLabsHotPocketBot collects the following data from HTML documents:
* Page title extracted from ``meta`` and ``title`` tags.
* Description extract from ``meta`` tags.
Metadata collected by BTHLabsHotPocketBot is stored in HotPocket database and
displayed to users who saved the link.
How BTHLabsHotPocketBot accesses your site
------------------------------------------
BTHLabsHotPocketBot makes an HTTP request to the site when the link is saved
or a user requests metadata refresh through the UI. The response content is
then processed. The response content **is not** stored in any database.
Getting Support
----------------
If you have any questions about BTHLabsHotPocketBot, please contact us at
contact@bthlabs.pl and we will respond as soon as possible.

View File

@ -1,12 +0,0 @@
<!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

@ -1,12 +0,0 @@
<!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

@ -8,12 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
USER root
# COPY --chown=$APP_USER_UID:$APP_USER_GID extension/ops/bin/*.sh /srv/bin/
RUN chown -R ${APP_USER_UID}:${APP_USER_GID} /srv
USER app
VOLUME ["/srv/node_modules", "/srv/venv"]
@ -22,5 +17,3 @@ FROM development AS ci
COPY --chown=$APP_USER_UID:$APP_USER_GID extension/ /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/packages/
COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/
RUN chown -R $APP_USER_UID:$APP_USER_GID /srv

View File

View File

@ -1,6 +1,6 @@
{
"name": "hotpocket-extension",
"version": "25.10.4",
"version": "25.10.13",
"description": "HotPocket Extension",
"main": "src/index.js",
"repository": "https://git.bthlabs.pl/tomekwojcik/hotpocket",

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "hotpocket-extension"
version = "25.10.4"
version = "25.10.13"
description = "HotPocket Extension"
authors = ["Tomek Wójcik <contact@bthlabs.pl>"]
license = "Apache-2.0"

View File

@ -164,7 +164,7 @@ const doHandleAuthFlow = (authTab) => {
);
const expectedSessionTabQuery = `?authSessionToken=${authSessionToken}`;
if (tabId !== currentAuthTabId && changedURL.includes(expectedSessionTabQuery)) {
if (tabId !== currentAuthTabId && changedURL && changedURL.includes(expectedSessionTabQuery)) {
// When redirecting from the preauth page to the HotPocket instance,
// Safari "replaces" the auth tab with a new one. This nasty hack will
// allow the extension to keep track of it.
@ -268,7 +268,7 @@ const doSetupRPC = async () => {
};
const doSendTabMessage = (tab, message) => {
HotPocketExtension.api.tabs.sendMessage(tab.id, message).
return HotPocketExtension.api.tabs.sendMessage(tab.id, message).
then((result) => {
HotPocketExtension.LOGGER.debug(
'HotPocketExtension.background.doSendTabMessage(): message sent',
@ -327,6 +327,10 @@ const onBrowserActionClicked = async (tab) => {
let result = false;
let error = null;
await doSendTabMessage(tab, {
type: 'HotPocket:Extension:browserActionClicked',
});
try {
let accessToken = await doSetupRPC();
@ -348,7 +352,7 @@ const onBrowserActionClicked = async (tab) => {
error: error,
};
doSendTabMessage(tab, message);
return await doSendTabMessage(tab, message);
};
const onMessage = (message, sender, sendResponse) => {

View File

@ -1,6 +1,7 @@
import HotPocketExtension from '../common';
import POPUP from './templates/popup.html';
import POPUP_CONTENT_SAVING from './templates/popup_content_saving.html';
import POPUP_CONTENT_SUCCESS from './templates/popup_content_success.html';
import POPUP_CONTENT_ERROR from './templates/popup_content_error.html';
@ -18,6 +19,19 @@ class Popup {
this.timeout = null;
}
};
setContent = (content) => {
const shadow = this.container.shadowRoot;
const body = shadow.querySelector('.hotpocket-extension-popup-body');
body.innerHTML = content;
const i18nElements = shadow.querySelectorAll('[data-message]');
for (let i18nElement of i18nElements) {
i18nElement.innerHTML = HotPocketExtension.api.i18n.getMessage(
i18nElement.dataset.message,
);
}
};
close = () => {
this.clearCloseTimeout();
@ -36,15 +50,7 @@ class Popup {
const shadow = this.container.attachShadow({mode: 'open'});
shadow.innerHTML = POPUP;
const body = shadow.querySelector('.hotpocket-extension-popup-body');
body.innerHTML = content;
const i18nElements = shadow.querySelectorAll('[data-message]');
for (let i18nElement of i18nElements) {
i18nElement.innerHTML = HotPocketExtension.api.i18n.getMessage(
i18nElement.dataset.message,
);
}
this.setContent(content);
const closeElements = shadow.querySelectorAll('.hotpocket-extension-popup-close');
for (const closeElement of closeElements) {
@ -53,6 +59,9 @@ class Popup {
document.body.appendChild(this.container);
};
update = (content) => {
this.setContent(content);
};
onCloseClick = (event) => {
this.close();
};
@ -60,15 +69,27 @@ class Popup {
let currentPopup = null;
const doHandleSaveMessage = (message) => {
const doHandleBrowserActionClickedMessage = (message) => {
if (currentPopup !== null) {
currentPopup.close();
}
currentPopup = new Popup();
currentPopup.show(
(message.result === true) ? POPUP_CONTENT_SUCCESS : POPUP_CONTENT_ERROR,
);
currentPopup.show(POPUP_CONTENT_SAVING);
};
const doHandleSaveMessage = (message) => {
let content = POPUP_CONTENT_ERROR;
if (message.result === true) {
content = POPUP_CONTENT_SUCCESS;
}
if (currentPopup === null) {
currentPopup = new Popup();
currentPopup.show(content);
} else {
currentPopup.update(content);
}
};
const doSendMessage = (message) => {
@ -93,7 +114,9 @@ export default ({...configuration}) => {
let response = {ok: true};
try {
if (message.type === 'HotPocket:Extension:save') {
if (message.type === 'HotPocket:Extension:browserActionClicked') {
doHandleBrowserActionClickedMessage(message);
} else if (message.type === 'HotPocket:Extension:save') {
doHandleSaveMessage(message);
}
} catch (exception) {

View File

@ -13,7 +13,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover,user-scalable=no">
<meta name="generator" content="pl.bthlabs.HotPocket.Extension@v25.10.4">
<meta name="generator" content="pl.bthlabs.HotPocket.Extension@v25.10.13">
<meta name="theme-color" content="#2b3035"/>
<title>HotPocket by BTHLabs</title>
<link rel="icon" type="image/png" sizes="32x32" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAACshmLzAAAH/0lEQVRYCbVXWWxcVxn+zp07i2e8p3Y8WRw3dtI4sZSmxC52ixBLIG5LS0JfqEQKvMETNJEqKlEhkJAQLUtf2QLKA6CkoVKa1CGtWlUNkFRpQ9PGJM7i2MR27LHH9tiz3nv5/v/O2OOO+wLlyPf6rP/3/eu5Y1DWuh9+vA8uDsGgj9NNfKyy5f+l6/LwJDycpcTnzr989GxJmGHHdPT3h+pN9MfGw3e8jw+0hLHiPwFdz+AXSW/xmaFTp3K2rAo42T3l8SV/0rTvdz+Wt6FZpRHc4uspYsrwkPnE3n0PGmPe8DzPerfnh7ALGVn4v7WCHcG9554FMV1iftomm4PU1vJcj+BpdJ77EWkWzfDf0pDzxtd4hQjOXe75PgSLdhBLHLSNhV6Z8DxXre85DnLhWsyv6dKztdOXEczOIFW3BdmoxKXfjOegcfycumw63sOzgeKKh5BJI1QbhJdMYsH4Z2qmLyGUnfMxiGU8C8YyvbQAmoQwzaEChMh84w7M3rUT1cmrcA3DhEtOIAzHVr/pPiEgoS3NsavYD3CbB6u9HbnuXhQWZhH8659RyFZhoX6rrq25/ZbuFyw1ErElCJlqykAX5SVUqpNX0HTrjPiK5gTqEpcAPgJSCijdS0l1+WHkdvTCTI8j09OHyMDvEX77NM9aqBJPtMnOsqbKqsKWLQI/3GqnP2BUBBVchEhzaxqQX9uK0NC7HPln3Eg1vGAY8weehX31ApwdDwABG5kvHoDTtBGxV37LvQZ1d96hyR0Rs6KpxVbM6MDQ50mEMwnVgC8NqLmvPg07OVncbpBftwXp+x9CvnUbrNkEqv74U4QGDiMwehWRYy8gv3sPnLVtqkQ4M0X/J3lWzLGyaR0onxKTZ2JxeHYYsdQIlwzcUAiFxriCBSIx2CNX4DRvQnbXZ2EW5xFIjKqI0MU3II+cyT40C7exGWZqBOm6TbAKeUQWx8uhtL9qqV2o34xUw9biZvo8m4E9+i+kvvAk5p74HhxxwfwUPJJxG1tgJm+rbuIufSxmWFUMJp1SGamGexiImyvAZWJVAuU7xduSGdGTvwOohUeQ7KYu5Dp2LW0L3PzABw4EYPHx6u9iLAQRmJ9Z2vNRnQoCflD6QaZvTRmm151hVA38gdY1SD/xNPI0vzRDkCAD0FBri4+xAnDbd8KaS8CamfBxVQtRxJfrT/rvCgIa4Nynm4sH3Jo1yN3TjcA/TsKaGC4/j+CF1zimmywmpxKg/+/bg9Dgec5XBt2KwxxUBKFsiCyMwQ1EdK8QSfd9CU7jOgQnRxmcIZ2Xl2FWhN487puflpEAznY9yADdgOqjz+tY9kXmRxFwVr9jKgmQdGz2hppazC3aea6LQtt2FL79M5HnNxILHvslTD4No6SYmuu3IrX3m4ieOQIrNesToIjamUE9w5uwolW4QKqcE4zqI/VSLBC8/s+Kg/bpI7CvXyIIRdD86b5HNUMibw8gcuFVBVeXcN0JxVReeQUtCay0AFeSTbvgsr43jZzxb67ZqdJ+IJ+DfeLXCJ47BYQiKHTej9wDj8JpaWPlO4zwxdd98KJL5OB0vA9WfhGlu2BZ2EfEAOsmXIv3lFrAZZ1niWUqBhhY1tkT9FEt8o99C05XLxCOIvj+WcRefAGBVHIZnAHJgXiQ8RRkISqHXe5XWEDSMDZzDcM7vo4Cb79GZwh5BqFo7mzrhkONwQIj5o+cOozQtXdgOYUisBQiPxsE3KMSI+37eZV3oO29X0k0LSMXe7b4pTTtWiHMN3SihpfRlvM/oSvuhZUZh3X6GOyZEZi5CZiZOyww08x5Fh3b9nNfKyAlicbU3LXCWKzZgChLeSx5DesH/0QzFHjNb+daWRYRO7Cuo/MHtLSyq04MYuLufsy09KCKqRObHoThvT5nr8dEfA9s1vxoYkirncVbzwpI4Vl+OIHplk9ieNsB2Pl5VM/wYkr9G5loHLe2fw2LdZvRevkIglwrBajZvXc/Xe3C5ZeQy3STlJtr7MR4+2M8PIrmK3+BnU4gF2nA5NZ9ek23vvcbFeBXPt/XkmE3O59k+BQQv/EygpkZ5EJ1GLv7YaSr1yN+7SXUJC4vnZOSrffG7v6vEN8Hdl1eMySjn2gUKNpMtu1F7fh5NN14hb5mMaHmhjflra5voGV4APyYxMTGPdg4dBTGYU2gjAJdcKf180g234fmm6fROPY3BiSrpcQH3SXuW7IcCQiqpVYQIhL5/M+OknH4YTLZ+jkk471Yc+tVTSV6GYu8LW+3f1kvp/jNk6hNvK/9Kabc5IbPoGH872gafo0Ec/T0cnwICbWc1A9+GZvu/v3j5LbWB/RBlz5Si9aQCMkHaxgfj2ChsQMt10+gfvKin2bFaJ5t2klzP6J+X0sXSAwIMEGWNNf7ohiwOg9vggQef5EA+/xMEAISkWKFIhm1RHGO/WysBWOMj0KwGuuGeJTtdsd+AqYQv/4SwgvjHwIWAhKoy2TISqixmeNGfw96eJMjfkUQVOaLoKVCVD4u9RfqOzDWsU92Iz50nOk2pJqqZkWt/X6xNnBOx3JA+kxMsviU9kjiOSIflFlpagW/o/1KIn6s+Gy5UWWLVuxIWiqB0n9dFGk6rx0d4Hn+SD2klXAxap6JLoqZ8V2uWVpQxAoqiAd5jXn6+MHJBUY7SSh9xS8D5tqSn30k3eZrLROi+c8F01+Vd7Gt+vO85BayW4qPoosqLCAgSlog1R465oDMV/95/h8tMX6C+Qt1JgAAAABJRU5ErkJggg==">
@ -79,7 +79,7 @@ body, html {
</div>
<p class="mb-0 mt-2 text-center text-muted ui-uname">
<span>
<a href="https://hotpocket.app/" target="_blank" rel="noopener noreferer">HotPocket by BTHLabs</a> v25.10.4
<a href="https://hotpocket.app/" target="_blank" rel="noopener noreferer">HotPocket by BTHLabs</a> v25.10.13
</span>
<br>
<span>Copyright &copy; 2025-present by BTHLabs. All rights reserved.</span>

View File

@ -58,6 +58,9 @@
.hotpocket-extension-popup .hotpocket-extension-popup-body > * {
margin: 0px;
}
.hotpocket-extension-popup .hotpocket-extension-popup-body > .hotpocket-extension-popup-loader {
margin: 0px auto;
}
.hotpocket-extension-popup .hotpocket-extension-popup-body strong {
font-weight: 600;
}
@ -67,6 +70,33 @@
.hotpocket-extension-popup .hotpocket-extension-popup-message-error {
color: #EE6476;
}
.hotpocket-extension-popup-loader {
animation: hotpocket-extension-popup-loader-animation 1s infinite steps(12);
aspect-ratio: 1;
background:
linear-gradient(0deg ,rgb(240 240 240/50%) 30%,#0000 0 70%,rgb(240 240 240/100%) 0) 50%/8% 100%,
linear-gradient(90deg,rgb(240 240 240/25%) 30%,#0000 0 70%,rgb(240 240 240/75% ) 0) 50%/100% 8%;
background-repeat: no-repeat;
border-radius: 50%;
display: grid;
width: 32px;
}
.hotpocket-extension-popup-loader::before,
.hotpocket-extension-popup-loader::after {
background: inherit;
border-radius: 50%;
content: "";
grid-area: 1/1;
opacity: 0.915;
transform: rotate(30deg);
}
.hotpocket-extension-popup-loader::after {
opacity: 0.83;
transform: rotate(60deg);
}
@keyframes hotpocket-extension-popup-loader-animation {
100% {transform: rotate(1turn)}
}
</style>
<div class="hotpocket-extension-popup">
<div class="hotpocket-extension-popup-header">

View File

@ -0,0 +1 @@
<div class="hotpocket-extension-popup-loader"></div>

View File

@ -3,7 +3,7 @@
"default_locale": "en",
"name": "__MSG_extension_name__",
"description": "__MSG_extension_description__",
"version": "25.10.4",
"version": "25.10.13",
"icons": {
"16": "images/icon-16.png",
"32": "images/icon-32.png",

View File

@ -8,12 +8,7 @@ ARG APP_USER_UID
ARG APP_USER_GID
ARG IMAGE_ID
USER root
# COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ops/bin/*.sh /srv/bin/
RUN chown -R ${APP_USER_UID}:${APP_USER_GID} /srv
USER app
VOLUME ["/srv/node_modules", "/srv/venv"]
@ -21,5 +16,3 @@ FROM development AS ci
COPY --chown=$APP_USER_UID:$APP_USER_GID packages/ /srv/app/
COPY --chown=$APP_USER_UID:$APP_USER_GID tls/ /srv/tls/
RUN chown -R $APP_USER_UID:$APP_USER_GID /srv

View File

View File

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import enum
class BackendServiceErrorCode(enum.Enum):
INTERNAL = -32000
NOT_FOUND = -32001
ACCESS_DENIED = -32002
INVALID = -32003

View File

@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from hotpocket_soa.constants import BackendServiceErrorCode
if typing.TYPE_CHECKING:
from django.core.exceptions import ValidationError
VALIDATION_CODE_INVALID = 'invalid'
def get_validation_error_data(validation_error: typing.Any) -> typing.Any: # Heh
if hasattr(validation_error, 'error_dict') is True:
return {
field: [
get_validation_error_data(inner_error)
for inner_error
in inner_errors
]
for field, inner_errors in validation_error.error_dict.items()
}
elif hasattr(validation_error, 'error_list') is True and len(validation_error.error_list) > 1:
return [
get_validation_error_data(inner_error)
for inner_error
in validation_error.error_list
]
elif hasattr(validation_error, 'code') is True:
return validation_error.code
elif isinstance(validation_error, (tuple, list)) is True:
return [
get_validation_error_data(inner_error)
for inner_error
in validation_error
]
elif isinstance(validation_error, dict) is True:
return {
field: [
get_validation_error_data(inner_error)
for inner_error
in inner_errors
]
for field, inner_errors in validation_error.items()
}
else:
return VALIDATION_CODE_INVALID
class BackendServiceError(Exception):
CODE = BackendServiceErrorCode.INTERNAL
def __init__(self, message: str, *args):
super().__init__(message, *args)
self.message = message
self.data: typing.Any = None
if len(args) > 0:
self.data = args[0]
class InternalError(BackendServiceError):
pass
class NotFound(BackendServiceError):
CODE = BackendServiceErrorCode.NOT_FOUND
class AccessDenied(BackendServiceError):
CODE = BackendServiceErrorCode.ACCESS_DENIED
class Invalid(BackendServiceError):
CODE = BackendServiceErrorCode.INVALID
@classmethod
def from_django_validation_error(cls: type[typing.Self],
exception: ValidationError,
message: str | None = None,
) -> typing.Self:
data = get_validation_error_data(exception)
result = cls(message or 'Invalid', data)
result.__cause__ = exception
return result

View File

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import typing
from .backend import BackendServiceError
class SOAError(Exception):
def __init__(self, code: int, message: str, *args):
super().__init__(code, message, *args)
self.code = code
self.message = message
self.data: typing.Any = None
if len(args) > 0:
self.data = args[0]
@classmethod
def from_backend_error(cls: type[typing.Self], exception: BackendServiceError) -> typing.Self:
result = cls(
exception.CODE.value,
exception.message,
exception.data,
)
result.__cause__ = exception
return result

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import http
import uuid
from hotpocket_backend.apps.accounts.services import (
@ -11,6 +12,7 @@ from hotpocket_soa.dto.accounts import (
AccessTokenOut,
AccessTokensQuery,
)
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError
@ -19,22 +21,18 @@ class AccessTokensService(ProxyService):
class AccessTokensServiceError(SOAError):
pass
class AccessTokenNotFound(AccessTokensServiceError):
class NotFound(AccessTokensServiceError):
pass
class AccessTokenAccessDenied(AccessTokensServiceError):
class AccessDenied(AccessTokensServiceError):
pass
def __init__(self):
super().__init__()
self.backend_access_tokens_service = BackendAccessTokensService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AccessTokensServiceError(*new_exception_args)
def get_error_class(self) -> type[SOAError]:
return self.AccessTokensServiceError
def create(self,
*,
@ -69,16 +67,14 @@ class AccessTokensService(ProxyService):
)
if result.account_uuid != account_uuid:
raise self.AccessTokenAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAccessTokensService.AccessTokenNotFound) is True:
raise self.AccessTokenNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def get_by_key(self,
*,
@ -96,16 +92,14 @@ class AccessTokensService(ProxyService):
)
if result.account_uuid != account_uuid:
raise self.AccessTokenAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
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
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def search(self,
*,
@ -124,17 +118,21 @@ class AccessTokensService(ProxyService):
]
def delete(self, *, access_token: AccessTokenOut) -> bool:
try:
return self.call(
self.backend_access_tokens_service,
'delete',
pk=access_token.pk,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def update_meta(self,
*,
access_token: AccessTokenOut,
update: AccessTokenMetaUpdateIn,
) -> AccessTokenOut:
try:
return AccessTokenOut.model_validate(
self.call(
self.backend_access_tokens_service,
@ -144,3 +142,5 @@ class AccessTokensService(ProxyService):
),
from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@ -7,6 +7,7 @@ from hotpocket_backend.apps.accounts.services import (
AccountsService as BackendAccountsService,
)
from hotpocket_soa.dto.accounts import AccountOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError
@ -15,19 +16,15 @@ class AccountsService(ProxyService):
class AccountsServiceError(SOAError):
pass
class AccountNotFound(AccountsServiceError):
class NotFound(AccountsServiceError):
pass
def __init__(self):
super().__init__()
self.backend_accounts_service = BackendAccountsService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AccountsServiceError(*new_exception_args)
def get_error_class(self) -> type[SOAError]:
return self.AccountsServiceError
def get(self, *, pk: uuid.UUID) -> AccountOut:
try:
@ -41,8 +38,5 @@ class AccountsService(ProxyService):
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAccountsService.AccountNotFound) is True:
raise self.AccountNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import datetime
import http
import uuid
from hotpocket_backend.apps.saves.services import (
@ -14,6 +15,7 @@ from hotpocket_soa.dto.associations import (
AssociationWithTargetOut,
)
from hotpocket_soa.dto.saves import SaveOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError
@ -22,22 +24,18 @@ class AssociationsService(ProxyService):
class AssociationsServiceError(SOAError):
pass
class AssociationNotFound(AssociationsServiceError):
class NotFound(AssociationsServiceError):
pass
class AssociationAccessDenied(AssociationsServiceError):
class AccessDenied(AssociationsServiceError):
pass
def __init__(self):
super().__init__()
self.backend_associations_service = BackendAssociationsService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AssociationsServiceError(*new_exception_args)
def get_error_class(self) -> type[SOAError]:
return self.AssociationsServiceError
def create(self,
*,
@ -81,19 +79,19 @@ class AssociationsService(ProxyService):
)
if allow_archived is False and result.archived_at is not None:
raise self.AssociationNotFound(f'pk=`{pk}`')
raise self.NotFound(
http.HTTPStatus.NOT_FOUND.value, f'pk=`{pk}`',
)
if account_uuid is not None and result.account_uuid != account_uuid:
raise self.AssociationAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAssociationsService.AssociationNotFound) is True:
raise self.AssociationNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def search(self,
*,
@ -112,13 +110,17 @@ class AssociationsService(ProxyService):
]
def archive(self, *, association: AssociationOut) -> bool:
try:
return self.call(
self.backend_associations_service,
'archive',
pk=association.pk,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def star(self, *, association: AssociationOut) -> AssociationOut:
try:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
@ -127,8 +129,11 @@ class AssociationsService(ProxyService):
),
from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def unstar(self, *, association: AssociationOut) -> AssociationOut:
try:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
@ -137,12 +142,15 @@ class AssociationsService(ProxyService):
),
from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def update(self,
*,
association: AssociationOut,
update: AssociationUpdateIn,
) -> AssociationOut:
try:
return AssociationOut.model_validate(
self.call(
self.backend_associations_service,
@ -152,10 +160,15 @@ class AssociationsService(ProxyService):
),
from_attributes=True,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def delete(self, *, association: AssociationOut) -> bool:
try:
return self.call(
self.backend_associations_service,
'delete',
pk=association.pk,
)
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import http
import uuid
from hotpocket_backend.apps.accounts.services import (
AuthKeysService as BackendAuthKeysService,
)
from hotpocket_soa.dto.accounts import AuthKeyOut
from hotpocket_soa.exceptions.backend import NotFound
from .base import ProxyService, SOAError
@ -15,23 +17,16 @@ class AuthKeysService(ProxyService):
class AuthKeysServiceError(SOAError):
pass
class AuthKeyNotFound(AuthKeysServiceError):
class NotFound(AuthKeysServiceError):
pass
class AuthKeyAccessDenied(AuthKeysServiceError):
class AccessDenied(AuthKeysServiceError):
pass
def __init__(self):
super().__init__()
self.backend_auth_keys_service = BackendAuthKeysService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.AuthKeysServiceError(*new_exception_args)
def _check_auth_key_access(self,
auth_key: AuthKeyOut,
account_uuid: uuid.UUID | None,
@ -70,16 +65,14 @@ class AuthKeysService(ProxyService):
)
if self._check_auth_key_access(result, account_uuid) is False:
raise self.AuthKeyAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` pk=`{pk}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True:
raise self.AuthKeyNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)
def get_by_key(self,
*,
@ -97,13 +90,11 @@ class AuthKeysService(ProxyService):
)
if self._check_auth_key_access(result, account_uuid) is False:
raise self.AuthKeyAccessDenied(
raise self.AccessDenied(
http.HTTPStatus.FORBIDDEN.value,
f'account_uuid=`{account_uuid}` key=`{key}`',
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendAuthKeysService.AuthKeyNotFound) is True:
raise self.AuthKeyNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@ -1,16 +1,66 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import functools
import http
import types
import typing
class SOAError(Exception):
pass
from hotpocket_soa.exceptions.backend import BackendServiceError
from hotpocket_soa.exceptions.frontend import SOAError
class Service:
def wrap_exception(self, exception: Exception) -> Exception:
return SOAError(exception.args[0])
def __getattribute__(self, name: str) -> typing.Any:
result = super().__getattribute__(name)
is_service_method = all((
name.startswith('_') is False,
hasattr(Service, name) is False,
isinstance(result, types.MethodType),
getattr(result, '__self__', None) is self,
))
if is_service_method is True:
@functools.wraps(result)
def wrapped_result(*args, **kwargs):
try:
return result(*args, **kwargs)
except Exception as exception:
raise self.wrap_exception(exception) from exception
return wrapped_result
return result
def get_error_class(self) -> type[SOAError]:
return SOAError
def wrap_exception(self, exception: Exception) -> SOAError:
error_class = self.get_error_class()
if isinstance(exception, error_class) is True:
return typing.cast(SOAError, exception)
result = error_class(
http.HTTPStatus.IM_A_TEAPOT.value,
'SOA Error',
*exception.args,
)
if isinstance(exception, BackendServiceError) is True:
baskend_error = typing.cast(BackendServiceError, exception)
result = error_class(
baskend_error.CODE.value,
baskend_error.message,
baskend_error.data,
*baskend_error.args,
)
result.__cause__ = exception
return result
def call(self, *args, **kwargs) -> typing.Any:
raise NotImplementedError('TODO')
class ProxyService(Service):
@ -18,7 +68,4 @@ class ProxyService(Service):
handler = getattr(service, method, None)
assert handler is not None, f'Unknown method: method=`{method}`'
try:
return handler(*args, **kwargs)
except Exception as exception:
raise self.wrap_exception(exception) from exception

View File

@ -15,12 +15,8 @@ class BotService(ProxyService):
super().__init__()
self.backend_associations_service = BackendBotService()
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.BotServiceError(*new_exception_args)
def get_error_class(self) -> type[SOAError]:
return self.BotServiceError
def is_netloc_banned(self, *, url: str) -> bool:
return self.call(

View File

@ -18,17 +18,13 @@ class SaveProcessorService(ProxyService):
class SaveProcessorServiceError(SOAError):
pass
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.SaveProcessorServiceError(*new_exception_args)
def __init__(self):
super().__init__()
self.backend_save_processor_service = BackendSaveProcessorService()
def get_error_class(self) -> type[SOAError]:
return self.SaveProcessorServiceError
def schedule_process_save(self, *, save: SaveOut) -> AsyncResultOut:
result = AsyncResultOut.model_validate(
self.call(

View File

@ -7,6 +7,7 @@ from hotpocket_backend.apps.saves.services import (
SavesService as BackendSavesService,
)
from hotpocket_soa.dto.saves import SaveIn, SaveOut
from hotpocket_soa.exceptions.backend import Invalid, NotFound
from .base import ProxyService, SOAError
@ -15,21 +16,21 @@ class SavesService(ProxyService):
class SavesServiceError(SOAError):
pass
class SaveNotFound(SavesServiceError):
class NotFound(SavesServiceError):
pass
def wrap_exception(self, exception: Exception) -> Exception:
new_exception_args = []
if len(exception.args) > 0:
new_exception_args = [exception.args[0]]
return self.SavesServiceError(*new_exception_args)
class Invalid(SavesServiceError):
pass
def __init__(self):
super().__init__()
self.backend_saves_service = BackendSavesService()
def get_error_class(self) -> type[SOAError]:
return self.SavesServiceError
def create(self, *, account_uuid: uuid.UUID, save: SaveIn) -> SaveOut:
try:
return SaveOut.model_validate(
self.call(
self.backend_saves_service,
@ -39,6 +40,10 @@ class SavesService(ProxyService):
),
from_attributes=True,
)
except Invalid as exception:
raise self.Invalid(
exception.CODE.value, exception.message, exception.data,
)
def get(self, *, pk: uuid.UUID) -> SaveOut:
try:
@ -52,8 +57,5 @@ class SavesService(ProxyService):
)
return result
except SOAError as exception:
if isinstance(exception.__cause__, BackendSavesService.SaveNotFound) is True:
raise self.SaveNotFound(*exception.args) from exception
else:
raise
except NotFound as exception:
raise self.NotFound.from_backend_error(exception)

View File

@ -24,8 +24,8 @@ server {
listen *:443 ssl;
server_name app.hotpocket.work.bthlabs.net;
ssl_certificate /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt;
ssl_certificate_key /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key;
ssl_certificate /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt;
ssl_certificate_key /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key;
location / {
# proxy_cache_bypass $http_upgrade;
@ -71,8 +71,8 @@ server {
listen *:443 ssl;
server_name admin.hotpocket.work.bthlabs.net;
ssl_certificate /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt;
ssl_certificate_key /Users/bilbo/Projects/PLAYG/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key;
ssl_certificate /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.crt;
ssl_certificate_key /Users/bilbo/Projects/HOTPOCKET/hotpocket/services/tls/app.hotpocket.work.bthlabs.net.key;
location / {
# proxy_cache_bypass $http_upgrade;