forked from cartsnitch/cartsnitch
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8e7a416d2 | |||
| f051e4b4af | |||
| 908ebde4c6 | |||
| c715c0e47a | |||
| c968088a3f | |||
| bb50ddc85d | |||
| bd2e8feff6 | |||
| 2b32bfdfe1 | |||
| 1e8223caeb | |||
| e1d77d7789 | |||
| 16200c5500 | |||
| 1803d09095 | |||
| 8592701382 | |||
| 17447fb5e1 | |||
| e29bad9a39 | |||
| 349b519a00 | |||
| b274fdff8e | |||
| a64dc7ab5e | |||
| 7fc524b593 | |||
| 0fb99e6c16 | |||
| a53daddb9a | |||
| 4e139dc4b6 | |||
| 3351d74058 | |||
| 1aff898545 | |||
| 6481cf03e4 | |||
| adfa34f2c2 | |||
| 37c75c3887 | |||
| ade03fdd1c | |||
| 8a0b2c03a1 | |||
| 5825174f0d | |||
| aa893d9cc1 | |||
| 91c062130c | |||
| 68e6be1985 | |||
| 0aef2455fd | |||
| c2a0263ddd | |||
| 24f0dd0e67 | |||
| 6602b8c105 | |||
| da96ec7dc4 | |||
| 37798251be | |||
| bc5e03e7a0 | |||
| dbbc8d2e7b | |||
| 1267caf43c | |||
| 015401861a | |||
| 9891e1aefb | |||
| 69ad161e36 | |||
| 485f890df3 | |||
| bf3ed0ede3 | |||
| 3f41eb7346 | |||
| 6cbd1ef298 | |||
| 94214f762e | |||
| 562c6ef6f6 | |||
| ccc8189d88 | |||
| 86594e4a8e | |||
| c2f1a83c1d | |||
| 6f8e5a9577 | |||
| bbfa816e57 | |||
| 5904eb03a2 | |||
| 87b6433ff7 | |||
| d7c9938f7e | |||
| 02434060ee |
+123
-8
@@ -13,6 +13,7 @@ concurrency:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
|
security-events: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
@@ -151,17 +152,44 @@ jobs:
|
|||||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: ${{ github.event_name == 'push' }}
|
load: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
target: prod
|
target: prod
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Scan frontend image for vulnerabilities
|
||||||
|
uses: anchore/scan-action@v5
|
||||||
|
id: scan
|
||||||
|
with:
|
||||||
|
image: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||||
|
fail-build: true
|
||||||
|
severity-cutoff: high
|
||||||
|
only-fixed: "true"
|
||||||
|
output-format: sarif
|
||||||
|
|
||||||
|
- name: Upload frontend scan results to GitHub Security
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
target: prod
|
||||||
|
cache-from: type=gha
|
||||||
|
|
||||||
- name: Create git tag
|
- name: Create git tag
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
run: |
|
run: |
|
||||||
@@ -221,14 +249,43 @@ jobs:
|
|||||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Build and push auth Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./auth
|
context: ./auth
|
||||||
file: ./auth/Dockerfile
|
file: ./auth/Dockerfile
|
||||||
push: ${{ github.event_name == 'push' }}
|
load: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Scan auth image for vulnerabilities
|
||||||
|
uses: anchore/scan-action@v5
|
||||||
|
id: scan
|
||||||
|
with:
|
||||||
|
image: "${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||||
|
fail-build: true
|
||||||
|
severity-cutoff: high
|
||||||
|
only-fixed: "true"
|
||||||
|
output-format: sarif
|
||||||
|
|
||||||
|
- name: Upload auth scan results to GitHub Security
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./auth
|
||||||
|
file: ./auth/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
|
||||||
build-and-push-receiptwitness:
|
build-and-push-receiptwitness:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
@@ -278,14 +335,43 @@ jobs:
|
|||||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Build and push receiptwitness image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./receiptwitness/Dockerfile
|
file: ./receiptwitness/Dockerfile
|
||||||
push: ${{ github.event_name == 'push' }}
|
load: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Scan receiptwitness image for vulnerabilities
|
||||||
|
uses: anchore/scan-action@v5
|
||||||
|
id: scan
|
||||||
|
with:
|
||||||
|
image: "${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||||
|
fail-build: true
|
||||||
|
severity-cutoff: high
|
||||||
|
only-fixed: "true"
|
||||||
|
output-format: sarif
|
||||||
|
|
||||||
|
- name: Upload receiptwitness scan results to GitHub Security
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./receiptwitness/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
|
||||||
build-and-push-api:
|
build-and-push-api:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
@@ -335,14 +421,43 @@ jobs:
|
|||||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Build and push API Docker image
|
- name: Build Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./api
|
context: ./api
|
||||||
file: ./api/Dockerfile
|
file: ./api/Dockerfile
|
||||||
push: ${{ github.event_name == 'push' }}
|
load: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Scan api image for vulnerabilities
|
||||||
|
uses: anchore/scan-action@v5
|
||||||
|
id: scan
|
||||||
|
with:
|
||||||
|
image: "${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||||
|
fail-build: true
|
||||||
|
severity-cutoff: high
|
||||||
|
only-fixed: "true"
|
||||||
|
output-format: sarif
|
||||||
|
|
||||||
|
- name: Upload api scan results to GitHub Security
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||||
|
|
||||||
|
- name: Push Docker image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./api
|
||||||
|
file: ./api/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
# Stage 1: Build
|
# Stage 1: Build
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
|
RUN apk update && apk upgrade --no-cache
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
@@ -11,6 +11,9 @@ RUN npm run build
|
|||||||
|
|
||||||
# Stage 2: Production — uses nginxinc/nginx-unprivileged which runs as non-root (UID 101)
|
# Stage 2: Production — uses nginxinc/nginx-unprivileged which runs as non-root (UID 101)
|
||||||
FROM nginxinc/nginx-unprivileged:stable-alpine AS prod
|
FROM nginxinc/nginx-unprivileged:stable-alpine AS prod
|
||||||
|
USER root
|
||||||
|
RUN apk update && apk upgrade --no-cache
|
||||||
|
USER 101
|
||||||
|
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
FROM python:3.12-slim AS build
|
FROM python:3.12-slim AS build
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
build-essential \
|
build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -12,7 +12,7 @@ RUN pip install --no-cache-dir --prefix=/install .
|
|||||||
|
|
||||||
FROM python:3.12-slim AS prod
|
FROM python:3.12-slim AS prod
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN adduser --system --group --uid 1000 app
|
RUN adduser --system --group --uid 1000 app
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Add GIN index on upc_variants and alter column to JSONB.
|
||||||
|
|
||||||
|
Revision ID: 009_add_gin_index_upc_variants
|
||||||
|
Revises: 008_create_domain_tables
|
||||||
|
Create Date: 2026-04-14
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "009_add_gin_index_upc_variants"
|
||||||
|
down_revision = "008_create_domain_tables"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.alter_column(
|
||||||
|
"normalized_products",
|
||||||
|
"upc_variants",
|
||||||
|
type_=sa.dialects.postgresql.JSONB(),
|
||||||
|
postgresql_using="upc_variants::jsonb",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_normalized_products_upc_variants_gin",
|
||||||
|
"normalized_products",
|
||||||
|
["upc_variants"],
|
||||||
|
postgresql_using="gin",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_normalized_products_upc_variants_gin", table_name="normalized_products")
|
||||||
|
op.alter_column(
|
||||||
|
"normalized_products",
|
||||||
|
"upc_variants",
|
||||||
|
type_=sa.JSON(),
|
||||||
|
)
|
||||||
@@ -1,26 +1,51 @@
|
|||||||
"""Redis/DragonflyDB caching helpers."""
|
"""Redis/DragonflyDB caching helpers."""
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
|
||||||
from cartsnitch_api.config import settings
|
from cartsnitch_api.config import settings
|
||||||
|
|
||||||
|
|
||||||
class CacheClient:
|
class CacheClient:
|
||||||
"""Stub for Redis/DragonflyDB caching.
|
"""Redis/DragonflyDB caching with connection pooling.
|
||||||
|
|
||||||
Will be used for expensive queries: price trends, product comparisons.
|
Will be used for expensive queries: price trends, product comparisons.
|
||||||
Cache invalidation via Redis pub/sub events from other services.
|
Cache invalidation via Redis pub/sub events from other services.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.url = settings.redis_url
|
self._pool: redis.ConnectionPool | None = None
|
||||||
|
self._client: redis.Redis | None = None
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Initialize the Redis connection pool."""
|
||||||
|
self._pool = redis.ConnectionPool.from_url(
|
||||||
|
settings.redis_url,
|
||||||
|
max_connections=20,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
self._client = redis.Redis(connection_pool=self._pool)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the Redis connection pool."""
|
||||||
|
if self._client:
|
||||||
|
await self._client.aclose()
|
||||||
|
if self._pool:
|
||||||
|
await self._pool.aclose()
|
||||||
|
|
||||||
async def get(self, key: str) -> str | None:
|
async def get(self, key: str) -> str | None:
|
||||||
# TODO: implement with redis-py async
|
if not self._client:
|
||||||
return None
|
return None
|
||||||
|
return await self._client.get(key)
|
||||||
|
|
||||||
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
|
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
|
||||||
# TODO: implement with redis-py async
|
if not self._client:
|
||||||
pass
|
return
|
||||||
|
await self._client.set(key, value, ex=ttl_seconds)
|
||||||
|
|
||||||
async def delete(self, key: str) -> None:
|
async def delete(self, key: str) -> None:
|
||||||
# TODO: implement with redis-py async
|
if not self._client:
|
||||||
pass
|
return
|
||||||
|
await self._client.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
cache_client = CacheClient()
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
|||||||
|
|
||||||
from cartsnitch_api.config import settings
|
from cartsnitch_api.config import settings
|
||||||
|
|
||||||
engine = create_async_engine(settings.database_url, echo=False)
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
)
|
||||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -14,3 +21,8 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
"""FastAPI dependency that yields an async DB session."""
|
"""FastAPI dependency that yields an async DB session."""
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def dispose_engine() -> None:
|
||||||
|
"""Dispose the database engine, closing all pooled connections."""
|
||||||
|
await engine.dispose()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import APIRouter, FastAPI
|
from fastapi import APIRouter, FastAPI
|
||||||
|
|
||||||
from cartsnitch_api.auth.routes import router as auth_router
|
from cartsnitch_api.auth.routes import router as auth_router
|
||||||
|
from cartsnitch_api.cache import cache_client
|
||||||
|
from cartsnitch_api.database import dispose_engine
|
||||||
from cartsnitch_api.middleware.cors import add_cors_middleware
|
from cartsnitch_api.middleware.cors import add_cors_middleware
|
||||||
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
|
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
|
||||||
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
|
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
|
||||||
@@ -23,9 +25,10 @@ from cartsnitch_api.routes.user import router as user_router
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# TODO: initialize DB session pool, Redis connection, service clients
|
await cache_client.initialize()
|
||||||
yield
|
yield
|
||||||
# TODO: cleanup connections
|
await cache_client.close()
|
||||||
|
await dispose_engine()
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ def add_cors_middleware(app: FastAPI) -> None:
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Content-Type", "Authorization", "Accept", "Origin", "X-Requested-With"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Uses in-memory sliding window as fallback, Redis/DragonflyDB when available.
|
|||||||
Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints.
|
Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
@@ -71,8 +72,8 @@ def _get_rate_limit_key(request: Request) -> tuple[str, _SlidingWindowCounter]:
|
|||||||
auth_header = request.headers.get("authorization", "")
|
auth_header = request.headers.get("authorization", "")
|
||||||
if auth_header.startswith("Bearer "):
|
if auth_header.startswith("Bearer "):
|
||||||
token = auth_header[7:]
|
token = auth_header[7:]
|
||||||
# Use last 16 chars of token as key to avoid storing full tokens
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||||
return f"token:{token[-16:]}", _auth_limiter
|
return f"token:{token_hash}", _auth_limiter
|
||||||
|
|
||||||
# Fallback to IP for unauthenticated non-public endpoints
|
# Fallback to IP for unauthenticated non-public endpoints
|
||||||
return f"ip:{_get_client_ip(request)}", _public_limiter
|
return f"ip:{_get_client_ip(request)}", _public_limiter
|
||||||
|
|||||||
@@ -18,14 +18,10 @@ router = APIRouter(prefix="/public", tags=["public"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/trends/{product_id}", response_model=PublicTrendResponse)
|
@router.get("/trends/{product_id}", response_model=PublicTrendResponse)
|
||||||
async def public_price_trend(
|
async def public_price_trend(product_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||||
product_id: UUID,
|
|
||||||
days: int = Query(90, ge=1, le=365),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
svc = PublicService(db)
|
svc = PublicService(db)
|
||||||
try:
|
try:
|
||||||
return await svc.get_trend(product_id, days=days)
|
return await svc.get_trend(product_id)
|
||||||
except LookupError:
|
except LookupError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
|
||||||
@@ -35,7 +31,6 @@ async def public_price_trend(
|
|||||||
@router.get("/store-comparison", response_model=PublicStoreComparisonResponse)
|
@router.get("/store-comparison", response_model=PublicStoreComparisonResponse)
|
||||||
async def public_store_comparison(
|
async def public_store_comparison(
|
||||||
product_ids: Annotated[list[UUID], Query(max_length=20)],
|
product_ids: Annotated[list[UUID], Query(max_length=20)],
|
||||||
category: str | None = Query(None, max_length=100, pattern=r"^[a-zA-Z0-9 _-]+$"),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
if not product_ids:
|
if not product_ids:
|
||||||
@@ -44,14 +39,10 @@ async def public_store_comparison(
|
|||||||
detail="At least one product_id is required",
|
detail="At least one product_id is required",
|
||||||
)
|
)
|
||||||
svc = PublicService(db)
|
svc = PublicService(db)
|
||||||
return await svc.get_store_comparison(product_ids, category=category)
|
return await svc.get_store_comparison(product_ids)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/inflation", response_model=PublicInflationResponse)
|
@router.get("/inflation", response_model=PublicInflationResponse)
|
||||||
async def public_inflation(
|
async def public_inflation(db: AsyncSession = Depends(get_db)):
|
||||||
category: str | None = Query(None, max_length=100, pattern=r"^[a-zA-Z0-9 _-]+$"),
|
|
||||||
period: str = Query("all-time", pattern=r"^(all-time|1y|6m|3m|1m)$"),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
svc = PublicService(db)
|
svc = PublicService(db)
|
||||||
return await svc.get_inflation(category=category, period=period)
|
return await svc.get_inflation()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Public service — unauthenticated price transparency endpoints."""
|
"""Public service — unauthenticated price transparency endpoints."""
|
||||||
|
|
||||||
from datetime import date, timedelta
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
@@ -14,7 +13,7 @@ class PublicService:
|
|||||||
def __init__(self, db: AsyncSession) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
async def get_trend(self, product_id: UUID, days: int = 90) -> dict:
|
async def get_trend(self, product_id: UUID) -> dict:
|
||||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||||
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
@@ -24,13 +23,9 @@ class PublicService:
|
|||||||
if not product:
|
if not product:
|
||||||
raise LookupError("Product not found")
|
raise LookupError("Product not found")
|
||||||
|
|
||||||
date_threshold = date.today() - timedelta(days=days)
|
|
||||||
prices_result = await self.db.execute(
|
prices_result = await self.db.execute(
|
||||||
select(PriceHistory)
|
select(PriceHistory)
|
||||||
.where(
|
.where(PriceHistory.normalized_product_id == product_id)
|
||||||
PriceHistory.normalized_product_id == product_id,
|
|
||||||
PriceHistory.observed_date >= date_threshold,
|
|
||||||
)
|
|
||||||
.options(selectinload(PriceHistory.store))
|
.options(selectinload(PriceHistory.store))
|
||||||
.order_by(PriceHistory.observed_date)
|
.order_by(PriceHistory.observed_date)
|
||||||
)
|
)
|
||||||
@@ -50,25 +45,20 @@ class PublicService:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_store_comparison(
|
async def get_store_comparison(self, product_ids: list[UUID]) -> dict:
|
||||||
self, product_ids: list[UUID], category: str | None = None
|
|
||||||
) -> dict:
|
|
||||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||||
|
|
||||||
if not product_ids:
|
if not product_ids:
|
||||||
return {"products": []}
|
return {"products": []}
|
||||||
|
|
||||||
product_query = select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
|
# Fetch all products in one query
|
||||||
if category:
|
prod_result = await self.db.execute(
|
||||||
product_query = product_query.where(NormalizedProduct.category == category)
|
select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
|
||||||
prod_result = await self.db.execute(product_query)
|
)
|
||||||
products_by_id = {p.id: p for p in prod_result.scalars().all()}
|
products_by_id = {p.id: p for p in prod_result.scalars().all()}
|
||||||
|
|
||||||
if not products_by_id:
|
# Latest prices for all requested products in one query
|
||||||
return {"products": []}
|
subq = latest_price_per_store(product_ids)
|
||||||
|
|
||||||
filtered_product_ids = list(products_by_id.keys())
|
|
||||||
subq = latest_price_per_store(filtered_product_ids)
|
|
||||||
prices_result = await self.db.execute(
|
prices_result = await self.db.execute(
|
||||||
select(PriceHistory)
|
select(PriceHistory)
|
||||||
.join(
|
.join(
|
||||||
@@ -79,17 +69,18 @@ class PublicService:
|
|||||||
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
|
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.where(PriceHistory.normalized_product_id.in_(filtered_product_ids))
|
.where(PriceHistory.normalized_product_id.in_(product_ids))
|
||||||
.options(selectinload(PriceHistory.store))
|
.options(selectinload(PriceHistory.store))
|
||||||
)
|
)
|
||||||
all_prices = prices_result.scalars().all()
|
all_prices = prices_result.scalars().all()
|
||||||
|
|
||||||
|
# Group by product
|
||||||
prices_by_product: dict[UUID, list] = {}
|
prices_by_product: dict[UUID, list] = {}
|
||||||
for ph in all_prices:
|
for ph in all_prices:
|
||||||
prices_by_product.setdefault(ph.normalized_product_id, []).append(ph)
|
prices_by_product.setdefault(ph.normalized_product_id, []).append(ph)
|
||||||
|
|
||||||
products = []
|
products = []
|
||||||
for pid in filtered_product_ids:
|
for pid in product_ids:
|
||||||
product = products_by_id.get(pid)
|
product = products_by_id.get(pid)
|
||||||
if not product:
|
if not product:
|
||||||
continue
|
continue
|
||||||
@@ -111,29 +102,19 @@ class PublicService:
|
|||||||
|
|
||||||
return {"products": products}
|
return {"products": products}
|
||||||
|
|
||||||
async def get_inflation(self, category: str | None = None, period: str = "all-time") -> dict:
|
async def get_inflation(self) -> dict:
|
||||||
"""Aggregate price change stats. Compares average prices across periods."""
|
"""Aggregate price change stats. Compares average prices across periods."""
|
||||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||||
|
|
||||||
date_threshold = None
|
# Get average prices grouped by category for recent vs older data
|
||||||
if period != "all-time":
|
result = await self.db.execute(
|
||||||
days_map = {"1y": 365, "6m": 180, "3m": 90, "1m": 30}
|
select(
|
||||||
days = days_map.get(period, 365)
|
NormalizedProduct.category,
|
||||||
date_threshold = date.today() - timedelta(days=days)
|
func.avg(PriceHistory.regular_price),
|
||||||
|
)
|
||||||
query = select(
|
.join(NormalizedProduct)
|
||||||
NormalizedProduct.category,
|
.group_by(NormalizedProduct.category)
|
||||||
func.avg(PriceHistory.regular_price),
|
)
|
||||||
).join(NormalizedProduct)
|
|
||||||
|
|
||||||
if category:
|
|
||||||
query = query.where(NormalizedProduct.category == category)
|
|
||||||
if date_threshold:
|
|
||||||
query = query.where(PriceHistory.observed_date >= date_threshold)
|
|
||||||
|
|
||||||
query = query.group_by(NormalizedProduct.category)
|
|
||||||
|
|
||||||
result = await self.db.execute(query)
|
|
||||||
categories = {}
|
categories = {}
|
||||||
for row in result.all():
|
for row in result.all():
|
||||||
cat, avg_price = row
|
cat, avg_price = row
|
||||||
@@ -141,7 +122,7 @@ class PublicService:
|
|||||||
categories[cat] = float(avg_price) if avg_price else 0.0
|
categories[cat] = float(avg_price) if avg_price else 0.0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"period": period,
|
"period": "all-time",
|
||||||
"cartsnitch_index": sum(categories.values()) / max(len(categories), 1),
|
"cartsnitch_index": sum(categories.values()) / max(len(categories), 1),
|
||||||
"cpi_baseline": 100.0,
|
"cpi_baseline": 100.0,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Tests for rate limiting middleware."""
|
"""Tests for rate limiting middleware."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter
|
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter, _get_rate_limit_key
|
||||||
|
|
||||||
|
|
||||||
class TestSlidingWindowCounter:
|
class TestSlidingWindowCounter:
|
||||||
@@ -53,3 +55,32 @@ async def test_health_skips_rate_limit(client):
|
|||||||
resp = await client.get("/health")
|
resp = await client.get("/health")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "x-ratelimit-limit" not in resp.headers
|
assert "x-ratelimit-limit" not in resp.headers
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRateLimitKey:
|
||||||
|
def _make_request(self, auth_header: str = "") -> MagicMock:
|
||||||
|
req = MagicMock()
|
||||||
|
req.url.path = "/purchases"
|
||||||
|
req.headers = {"authorization": auth_header} if auth_header else {}
|
||||||
|
return req
|
||||||
|
|
||||||
|
def test_distinct_tokens_produce_distinct_keys(self):
|
||||||
|
req1 = self._make_request("Bearer token_alpha_12345")
|
||||||
|
req2 = self._make_request("Bearer token_beta_67890")
|
||||||
|
key1, _ = _get_rate_limit_key(req1)
|
||||||
|
key2, _ = _get_rate_limit_key(req2)
|
||||||
|
assert key1 != key2
|
||||||
|
|
||||||
|
def test_same_token_produces_same_key(self):
|
||||||
|
req1 = self._make_request("Bearer same_token_value_abc")
|
||||||
|
req2 = self._make_request("Bearer same_token_value_abc")
|
||||||
|
key1, _ = _get_rate_limit_key(req1)
|
||||||
|
key2, _ = _get_rate_limit_key(req2)
|
||||||
|
assert key1 == key2
|
||||||
|
|
||||||
|
def test_key_does_not_contain_raw_token_suffix(self):
|
||||||
|
raw_token = "my_secret_jwt_token_xyz"
|
||||||
|
req = self._make_request(f"Bearer {raw_token}")
|
||||||
|
key, _ = _get_rate_limit_key(req)
|
||||||
|
assert raw_token[-16:] not in key
|
||||||
|
assert raw_token not in key
|
||||||
|
|||||||
@@ -71,97 +71,3 @@ async def test_public_inflation(client, public_data):
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert "categories" in data
|
assert "categories" in data
|
||||||
assert "cartsnitch_index" in data
|
assert "cartsnitch_index" in data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trend_invalid_uuid(client):
|
|
||||||
resp = await client.get("/public/trends/not-a-uuid")
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert "detail" in resp.json()
|
|
||||||
assert "stack" not in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trend_days_zero(client, public_data):
|
|
||||||
pid = str(public_data["product"].id)
|
|
||||||
resp = await client.get(f"/public/trends/{pid}?days=0")
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert "detail" in resp.json()
|
|
||||||
assert "stack" not in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trend_days_negative(client, public_data):
|
|
||||||
pid = str(public_data["product"].id)
|
|
||||||
resp = await client.get(f"/public/trends/{pid}?days=-1")
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert "detail" in resp.json()
|
|
||||||
assert "stack" not in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trend_days_over_max(client, public_data):
|
|
||||||
pid = str(public_data["product"].id)
|
|
||||||
resp = await client.get(f"/public/trends/{pid}?days=999")
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert "detail" in resp.json()
|
|
||||||
assert "stack" not in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trend_days_valid(client, public_data):
|
|
||||||
pid = str(public_data["product"].id)
|
|
||||||
resp = await client.get(f"/public/trends/{pid}?days=30")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert "product_name" in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_comparison_empty_list(client):
|
|
||||||
resp = await client.get("/public/store-comparison")
|
|
||||||
assert resp.status_code == 400
|
|
||||||
assert "detail" in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_comparison_category_xss(client, public_data):
|
|
||||||
pid = str(public_data["product"].id)
|
|
||||||
resp = await client.get(
|
|
||||||
f"/public/store-comparison?product_ids={pid}&category=<script>alert(1)</script>"
|
|
||||||
)
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert "detail" in resp.json()
|
|
||||||
assert "stack" not in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_store_comparison_category_sql_injection(client, public_data):
|
|
||||||
pid = str(public_data["product"].id)
|
|
||||||
resp = await client.get(f"/public/store-comparison?product_ids={pid}&category='; DROP TABLE--")
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert "detail" in resp.json()
|
|
||||||
assert "stack" not in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inflation_invalid_period(client, public_data):
|
|
||||||
resp = await client.get("/public/inflation?period=10years")
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert "detail" in resp.json()
|
|
||||||
assert "stack" not in resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inflation_valid_periods(client, public_data):
|
|
||||||
for period in ["all-time", "1y", "6m", "3m", "1m"]:
|
|
||||||
resp = await client.get(f"/public/inflation?period={period}")
|
|
||||||
assert resp.status_code == 200, f"period={period} failed"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_inflation_category_too_long(client, public_data):
|
|
||||||
long_category = "x" * 200
|
|
||||||
resp = await client.get(f"/public/inflation?category={long_category}")
|
|
||||||
assert resp.status_code == 422
|
|
||||||
assert "detail" in resp.json()
|
|
||||||
assert "stack" not in resp.json()
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM node:22-alpine AS builder
|
FROM node:22-alpine AS builder
|
||||||
|
RUN apk update && apk upgrade --no-cache
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -7,6 +8,7 @@ COPY src/ src/
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
RUN apk update && apk upgrade --no-cache
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|||||||
Generated
+3
-3
@@ -941,9 +941,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/defu": {
|
"node_modules/defu": {
|
||||||
"version": "6.1.4",
|
"version": "6.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/delegates": {
|
"node_modules/delegates": {
|
||||||
|
|||||||
+12
-6
@@ -4,17 +4,23 @@ import pg from "pg";
|
|||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString:
|
|
||||||
process.env.DATABASE_URL ??
|
|
||||||
"postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
|
||||||
});
|
|
||||||
|
|
||||||
const secret = process.env.BETTER_AUTH_SECRET;
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.warn(
|
||||||
|
"WARNING: DATABASE_URL is not set — using default localhost connection. " +
|
||||||
|
"Set DATABASE_URL for production deployments."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: databaseUrl ?? "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||||
|
});
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: pool,
|
database: pool,
|
||||||
basePath: "/auth",
|
basePath: "/auth",
|
||||||
|
|||||||
+17
-3
@@ -1,6 +1,6 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { toNodeHandler } from "better-auth/node";
|
import { toNodeHandler } from "better-auth/node";
|
||||||
import { auth } from "./auth.js";
|
import { auth, pool } from "./auth.js";
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||||
|
|
||||||
@@ -9,8 +9,22 @@ const handler = toNodeHandler(auth);
|
|||||||
const server = createServer(async (req, res) => {
|
const server = createServer(async (req, res) => {
|
||||||
// Health check
|
// Health check
|
||||||
if (req.url === "/health" && req.method === "GET") {
|
if (req.url === "/health" && req.method === "GET") {
|
||||||
res.writeHead(200, { "Content-Type": "application/json" });
|
try {
|
||||||
res.end(JSON.stringify({ status: "ok" }));
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
client.query("SELECT 1"),
|
||||||
|
new Promise((_, reject) => setTimeout(() => reject(new Error("DB timeout")), 2000)),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ status: "ok", db: "connected" }));
|
||||||
|
} catch {
|
||||||
|
res.writeHead(503, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ status: "error", db: "unreachable" }));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Submodule
+1
Submodule cartsnitch added at a53daddb9a
@@ -3,6 +3,7 @@
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from sqlalchemy import JSON, String
|
from sqlalchemy import JSON, String
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from cartsnitch_common.constants import ProductCategory, SizeUnit
|
from cartsnitch_common.constants import ProductCategory, SizeUnit
|
||||||
@@ -26,7 +27,9 @@ class NormalizedProduct(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
brand: Mapped[str | None] = mapped_column(String(200))
|
brand: Mapped[str | None] = mapped_column(String(200))
|
||||||
size: Mapped[str | None] = mapped_column(String(50))
|
size: Mapped[str | None] = mapped_column(String(50))
|
||||||
size_unit: Mapped[SizeUnit | None] = mapped_column(String(10))
|
size_unit: Mapped[SizeUnit | None] = mapped_column(String(10))
|
||||||
upc_variants: Mapped[list[str] | None] = mapped_column(JSON, default=list)
|
upc_variants: Mapped[list[str] | None] = mapped_column(
|
||||||
|
JSON().with_variant(JSONB(), "postgresql"), default=list
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
|
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ server {
|
|||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||||
gzip_min_length 256;
|
gzip_min_length 256;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.cartsnitch.com https://*.farh.net; frame-ancestors 'self'" always;
|
||||||
|
|
||||||
# Health endpoint for K8s probes
|
# Health endpoint for K8s probes
|
||||||
location /health {
|
location /health {
|
||||||
access_log off;
|
access_log off;
|
||||||
|
|||||||
Generated
+3
-3
@@ -9805,9 +9805,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.1",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
# build-essential and libpq-dev are needed to compile any C-extension wheels
|
# build-essential and libpq-dev are needed to compile any C-extension wheels
|
||||||
# (e.g. psycopg2 fallback). No git needed — common/ is copied from the repo root.
|
# (e.g. psycopg2 fallback). No git needed — common/ is copied from the repo root.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
build-essential \
|
build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -25,7 +25,7 @@ FROM python:3.12-slim AS prod
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Playwright system dependencies for Chromium
|
# Install Playwright system dependencies for Chromium
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||||
libnss3 \
|
libnss3 \
|
||||||
libatk1.0-0 \
|
libatk1.0-0 \
|
||||||
libatk-bridge2.0-0 \
|
libatk-bridge2.0-0 \
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
"""Service-specific configuration for ReceiptWitness."""
|
"""Service-specific configuration for ReceiptWitness."""
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
_PLACEHOLDER_VALUES = {"change-me-in-production"}
|
||||||
|
|
||||||
|
|
||||||
class ReceiptWitnessSettings(BaseSettings):
|
class ReceiptWitnessSettings(BaseSettings):
|
||||||
model_config = {"env_prefix": "RW_"}
|
model_config = {"env_prefix": "RW_"}
|
||||||
|
|
||||||
@@ -30,5 +34,34 @@ class ReceiptWitnessSettings(BaseSettings):
|
|||||||
# Mailgun inbound email webhook
|
# Mailgun inbound email webhook
|
||||||
mailgun_webhook_signing_key: str = ""
|
mailgun_webhook_signing_key: str = ""
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_required_vars(self):
|
||||||
|
errors = []
|
||||||
|
if not self.session_encryption_key or self.session_encryption_key in _PLACEHOLDER_VALUES:
|
||||||
|
errors.append(
|
||||||
|
"RW_SESSION_ENCRYPTION_KEY must be set to a secure value. "
|
||||||
|
'Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
||||||
|
)
|
||||||
|
if self.notifications_enabled and not self.resend_api_key:
|
||||||
|
errors.append(
|
||||||
|
"RW_RESEND_API_KEY must be set when RW_NOTIFICATIONS_ENABLED=true. "
|
||||||
|
"Get an API key from https://resend.com/api-keys"
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
raise ValueError(
|
||||||
|
"ReceiptWitness startup failed — missing required config:\n"
|
||||||
|
+ "\n".join(f" - {e}" for e in errors)
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
settings = ReceiptWitnessSettings()
|
|
||||||
|
class _LazySettings:
|
||||||
|
_instance: ReceiptWitnessSettings | None = None
|
||||||
|
|
||||||
|
def __getattr__(self, name: str):
|
||||||
|
if _LazySettings._instance is None:
|
||||||
|
_LazySettings._instance = ReceiptWitnessSettings()
|
||||||
|
return getattr(_LazySettings._instance, name)
|
||||||
|
|
||||||
|
|
||||||
|
settings = _LazySettings()
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ Matches products across retailers by:
|
|||||||
2. Fuzzy name matching via token-based Jaccard similarity (lower confidence)
|
2. Fuzzy name matching via token-based Jaccard similarity (lower confidence)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
|
||||||
from cartsnitch_common.models.product import NormalizedProduct
|
from cartsnitch_common.models.product import NormalizedProduct
|
||||||
from sqlalchemy import select
|
from sqlalchemy import cast, func, select, String
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
@@ -96,17 +98,24 @@ def jaccard_similarity(a: str, b: str) -> float:
|
|||||||
def match_by_upc(session: Session, upc: str) -> MatchResult | None:
|
def match_by_upc(session: Session, upc: str) -> MatchResult | None:
|
||||||
"""Find a normalized product by exact UPC match.
|
"""Find a normalized product by exact UPC match.
|
||||||
|
|
||||||
Loads products with upc_variants and checks membership in Python
|
Uses PostgreSQL JSONB containment (@>) for production efficiency.
|
||||||
for cross-database compatibility (works on both PostgreSQL and SQLite).
|
Falls back to LIKE on SQLite for test compatibility.
|
||||||
"""
|
"""
|
||||||
# TODO: Use PostgreSQL JSON containment query (@>) for production.
|
dialect_name = session.bind.dialect.name if session.bind else "default"
|
||||||
# Current approach loads all products into memory — acceptable for tests
|
if dialect_name == "postgresql":
|
||||||
# and small datasets, but will not scale.
|
stmt = select(NormalizedProduct).where(
|
||||||
stmt = select(NormalizedProduct).where(NormalizedProduct.upc_variants.is_not(None))
|
cast(NormalizedProduct.upc_variants, JSONB).op("@>")(
|
||||||
products = session.execute(stmt).scalars().all()
|
func.cast(json.dumps([upc]), JSONB)
|
||||||
for product in products:
|
)
|
||||||
if product.upc_variants and upc in product.upc_variants:
|
)
|
||||||
return MatchResult(product=product, confidence=1.0, method=MatchMethod.UPC)
|
else:
|
||||||
|
stmt = select(NormalizedProduct).where(
|
||||||
|
NormalizedProduct.upc_variants.is_not(None),
|
||||||
|
cast(NormalizedProduct.upc_variants, String).contains(upc),
|
||||||
|
)
|
||||||
|
product = session.execute(stmt).scalars().first()
|
||||||
|
if product:
|
||||||
|
return MatchResult(product=product, confidence=1.0, method=MatchMethod.UPC)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"""Shared test fixtures."""
|
"""Shared test fixtures."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
os.environ.setdefault("RW_SESSION_ENCRYPTION_KEY", "test-secret-key-for-unit-tests-only-32bytes!")
|
||||||
|
os.environ.setdefault("RW_MAILGUN_WEBHOOK_SIGNING_KEY", "test-mailgun-signing-key")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def meijer_receipt_data() -> dict:
|
def meijer_receipt_data() -> dict:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import pytest
|
||||||
|
from receiptwitness.config import ReceiptWitnessSettings
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_config():
|
||||||
|
s = ReceiptWitnessSettings(
|
||||||
|
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
|
||||||
|
)
|
||||||
|
assert s.session_encryption_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_session_encryption_key_raises():
|
||||||
|
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
|
||||||
|
ReceiptWitnessSettings(session_encryption_key="")
|
||||||
|
|
||||||
|
|
||||||
|
def test_placeholder_session_encryption_key_raises():
|
||||||
|
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
|
||||||
|
ReceiptWitnessSettings(session_encryption_key="change-me-in-production")
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifications_enabled_without_resend_key_raises():
|
||||||
|
with pytest.raises(ValueError, match="RW_RESEND_API_KEY"):
|
||||||
|
ReceiptWitnessSettings(
|
||||||
|
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
||||||
|
notifications_enabled=True,
|
||||||
|
resend_api_key="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifications_disabled_without_resend_key_ok():
|
||||||
|
s = ReceiptWitnessSettings(
|
||||||
|
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
||||||
|
notifications_enabled=False,
|
||||||
|
resend_api_key="",
|
||||||
|
)
|
||||||
|
assert s.notifications_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_notifications_enabled_with_resend_key_ok():
|
||||||
|
s = ReceiptWitnessSettings(
|
||||||
|
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
||||||
|
notifications_enabled=True,
|
||||||
|
resend_api_key="re_test_1234567890",
|
||||||
|
)
|
||||||
|
assert s.resend_api_key == "re_test_1234567890"
|
||||||
Reference in New Issue
Block a user