forked from cartsnitch/cartsnitch
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cc2bb78e9 | |||
| c9075be6e0 | |||
| 6c297b5e81 | |||
| 80004e4285 | |||
| 94f99595fc | |||
| c8de30ec6e | |||
| c1dc3e77e0 | |||
| 1af98c40ab | |||
| 1aaa8e78fd | |||
| c3bfd3560b | |||
| d52fb83296 | |||
| 7c45b04dce |
@@ -334,8 +334,8 @@ jobs:
|
|||||||
- name: Build and push API Docker image
|
- name: Build and push API Docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ./api
|
||||||
file: ./api/Dockerfile
|
file: ./Dockerfile
|
||||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -400,3 +400,64 @@ jobs:
|
|||||||
git add apps/overlays/dev/kustomization.yaml
|
git add apps/overlays/dev/kustomization.yaml
|
||||||
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
|
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|
||||||
|
deploy-uat:
|
||||||
|
runs-on: runners-cartsnitch
|
||||||
|
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
|
||||||
|
if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
steps:
|
||||||
|
- name: Generate GitHub App token
|
||||||
|
id: app-token
|
||||||
|
uses: actions/create-github-app-token@v1
|
||||||
|
with:
|
||||||
|
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
|
||||||
|
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
repositories: infra
|
||||||
|
|
||||||
|
- name: Checkout infra repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
repository: cartsnitch/infra
|
||||||
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
|
ref: main
|
||||||
|
path: infra
|
||||||
|
|
||||||
|
- name: Install kubectl
|
||||||
|
uses: azure/setup-kubectl@v4
|
||||||
|
|
||||||
|
- name: Install kustomize
|
||||||
|
uses: imranismail/setup-kustomize@v2
|
||||||
|
|
||||||
|
- name: Update frontend image tag
|
||||||
|
if: needs.build-and-push.result == 'success'
|
||||||
|
run: |
|
||||||
|
cd infra/apps/overlays/uat
|
||||||
|
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
|
||||||
|
|
||||||
|
- name: Update auth image tag
|
||||||
|
if: needs.build-and-push-auth.result == 'success'
|
||||||
|
run: |
|
||||||
|
cd infra/apps/overlays/uat
|
||||||
|
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
|
||||||
|
|
||||||
|
- name: Update receiptwitness image tag
|
||||||
|
if: needs.build-and-push-receiptwitness.result == 'success'
|
||||||
|
run: |
|
||||||
|
cd infra/apps/overlays/uat
|
||||||
|
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}
|
||||||
|
|
||||||
|
- name: Update api image tag
|
||||||
|
if: needs.build-and-push-api.result == 'success'
|
||||||
|
run: |
|
||||||
|
cd infra/apps/overlays/uat
|
||||||
|
kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }}
|
||||||
|
|
||||||
|
- name: Commit and push to infra
|
||||||
|
run: |
|
||||||
|
cd infra
|
||||||
|
git config user.name "cartsnitch-ci[bot]"
|
||||||
|
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||||
|
git add apps/overlays/uat/kustomization.yaml
|
||||||
|
git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images"
|
||||||
|
git push origin main
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ from cartsnitch_api.services.auth import AuthService
|
|||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
class EmailInAddressResponse(BaseModel):
|
|
||||||
email_address: str
|
|
||||||
instructions: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def get_me(
|
async def get_me(
|
||||||
user_id: str = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
@@ -70,23 +65,3 @@ async def delete_me(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
|
|
||||||
async def get_email_in_address(
|
|
||||||
user_id: str = Depends(get_current_user),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
result = await db.execute(select(User.email_inbound_token).where(User.id == user_id))
|
|
||||||
token = result.scalar_one_or_none()
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail="Email inbound token not found"
|
|
||||||
) from None
|
|
||||||
return EmailInAddressResponse(
|
|
||||||
email_address=f"receipts+{token}@receipts.cartsnitch.com",
|
|
||||||
instructions=(
|
|
||||||
"Forward your digital receipt emails to this address. "
|
|
||||||
"We currently support Meijer, Kroger, and Target receipt emails."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from cartsnitch_api.routes.purchases import router as purchases_router
|
|||||||
from cartsnitch_api.routes.scraping import router as scraping_router
|
from cartsnitch_api.routes.scraping import router as scraping_router
|
||||||
from cartsnitch_api.routes.shopping import router as shopping_router
|
from cartsnitch_api.routes.shopping import router as shopping_router
|
||||||
from cartsnitch_api.routes.stores import router as stores_router
|
from cartsnitch_api.routes.stores import router as stores_router
|
||||||
|
from cartsnitch_api.routes.user import router as user_router
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -49,6 +50,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# Data endpoints mounted under /api/v1
|
# Data endpoints mounted under /api/v1
|
||||||
v1_router = APIRouter(prefix="/api/v1")
|
v1_router = APIRouter(prefix="/api/v1")
|
||||||
|
v1_router.include_router(user_router)
|
||||||
v1_router.include_router(stores_router)
|
v1_router.include_router(stores_router)
|
||||||
v1_router.include_router(purchases_router)
|
v1_router.include_router(purchases_router)
|
||||||
v1_router.include_router(products_router)
|
v1_router.include_router(products_router)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""User routes: per-user account endpoints (email-in address, etc.)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from cartsnitch_api.auth.dependencies import get_current_user
|
||||||
|
from cartsnitch_api.database import get_db
|
||||||
|
from cartsnitch_api.schemas import EmailInAddressResponse
|
||||||
|
from cartsnitch_api.services.auth import AuthService
|
||||||
|
|
||||||
|
router = APIRouter(tags=["user"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
|
||||||
|
async def get_email_in_address(
|
||||||
|
user_id: str = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = AuthService(db)
|
||||||
|
try:
|
||||||
|
email_address = await svc.get_email_in_address(user_id)
|
||||||
|
return EmailInAddressResponse(
|
||||||
|
email_address=email_address,
|
||||||
|
instructions=(
|
||||||
|
"Forward your digital receipt emails to this address. "
|
||||||
|
"We currently support Meijer, Kroger, and Target receipt emails."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except LookupError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||||
|
) from None
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Pydantic v2 request/response schemas for all API endpoints."""
|
"""Pydantic v2 request/response schemas for all API endpoints."""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
@@ -21,6 +22,11 @@ class UserResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EmailInAddressResponse(BaseModel):
|
||||||
|
email_address: str
|
||||||
|
instructions: str
|
||||||
|
|
||||||
|
|
||||||
# ---------- Stores ----------
|
# ---------- Stores ----------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -66,3 +66,14 @@ class AuthService:
|
|||||||
|
|
||||||
await self.db.delete(user)
|
await self.db.delete(user)
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
||||||
|
async def get_email_in_address(self, user_id: str) -> str:
|
||||||
|
"""Return the per-user email-in address for receipt forwarding."""
|
||||||
|
from cartsnitch_api.models import User
|
||||||
|
|
||||||
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise LookupError("User not found")
|
||||||
|
|
||||||
|
return f"receipts+{user.email_inbound_token}@receipts.cartsnitch.com"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for GET /auth/me/email-in-address endpoint."""
|
"""Tests for GET /api/v1/me/email-in-address endpoint."""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
@@ -8,7 +8,7 @@ from httpx import AsyncClient
|
|||||||
async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict):
|
async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict):
|
||||||
"""Authenticated user gets their email-in address."""
|
"""Authenticated user gets their email-in address."""
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/auth/me/email-in-address",
|
"/api/v1/me/email-in-address",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ async def test_get_email_in_address_authenticated(client: AsyncClient, auth_head
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_email_in_address_unauthenticated(client: AsyncClient):
|
async def test_get_email_in_address_unauthenticated(client: AsyncClient):
|
||||||
"""Unauthenticated request returns 401."""
|
"""Unauthenticated request returns 401."""
|
||||||
response = await client.get("/auth/me/email-in-address")
|
response = await client.get("/api/v1/me/email-in-address")
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ async def test_get_email_in_address_unauthenticated(client: AsyncClient):
|
|||||||
async def test_get_email_in_address_invalid_token(client: AsyncClient):
|
async def test_get_email_in_address_invalid_token(client: AsyncClient):
|
||||||
"""Invalid JWT token returns 401."""
|
"""Invalid JWT token returns 401."""
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/auth/me/email-in-address",
|
"/api/v1/me/email-in-address",
|
||||||
headers={"Authorization": "Bearer invalid-token-xyz"},
|
headers={"Authorization": "Bearer invalid-token-xyz"},
|
||||||
)
|
)
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
@@ -45,7 +45,7 @@ async def test_get_email_in_address_invalid_token(client: AsyncClient):
|
|||||||
async def test_email_address_format(client: AsyncClient, auth_headers: dict):
|
async def test_email_address_format(client: AsyncClient, auth_headers: dict):
|
||||||
"""Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com."""
|
"""Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com."""
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
"/auth/me/email-in-address",
|
"/api/v1/me/email-in-address",
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Generated
+548
-650
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -50,6 +50,9 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@rollup/pluginutils": "5.3.0",
|
"@rollup/pluginutils": "5.3.0",
|
||||||
"flatted": "^3.4.2",
|
"flatted": "^3.4.2",
|
||||||
"serialize-javascript": "7.0.5"
|
"serialize-javascript": "7.0.5",
|
||||||
|
"brace-expansion": ">=1.1.13",
|
||||||
|
"lodash": ">=4.17.24",
|
||||||
|
"minimatch": "^10.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ TOKEN_PATTERN = re.compile(r"receipts\+([A-Za-z0-9_-]+)@")
|
|||||||
|
|
||||||
def verify_mailgun_signature(token: str, timestamp: str, signature: str) -> bool:
|
def verify_mailgun_signature(token: str, timestamp: str, signature: str) -> bool:
|
||||||
"""Verify Mailgun webhook signature."""
|
"""Verify Mailgun webhook signature."""
|
||||||
if abs(time.time() - int(timestamp)) > 300: # 5 min freshness
|
try:
|
||||||
|
ts = int(timestamp)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
if abs(time.time() - ts) > 300: # 5 min freshness
|
||||||
return False
|
return False
|
||||||
key = settings.mailgun_webhook_signing_key.encode()
|
key = settings.mailgun_webhook_signing_key.encode()
|
||||||
hmac_digest = hmac.new(key, f"{timestamp}{token}".encode(), hashlib.sha256).hexdigest()
|
hmac_digest = hmac.new(key, f"{timestamp}{token}".encode(), hashlib.sha256).hexdigest()
|
||||||
|
|||||||
@@ -99,3 +99,27 @@ def test_stale_timestamp(client, mock_redis):
|
|||||||
assert response.status_code == 406
|
assert response.status_code == 406
|
||||||
assert response.json()["detail"] == "Invalid signature"
|
assert response.json()["detail"] == "Invalid signature"
|
||||||
mock_redis["enqueue"].assert_not_awaited()
|
mock_redis["enqueue"].assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_timestamp_returns_406(client, mock_redis):
|
||||||
|
"""Empty timestamp should return 406, not 500."""
|
||||||
|
with patch("receiptwitness.api.routes.settings") as mock_settings:
|
||||||
|
mock_settings.mailgun_webhook_signing_key = "test-secret"
|
||||||
|
form = {
|
||||||
|
"token": "test-token",
|
||||||
|
"timestamp": "",
|
||||||
|
"signature": "any-sig",
|
||||||
|
"sender": "sender@example.com",
|
||||||
|
"recipient": "receipts+user123@example.com",
|
||||||
|
"subject": "Receipt",
|
||||||
|
}
|
||||||
|
response = client.post("/inbound/email", data=form)
|
||||||
|
assert response.status_code == 406
|
||||||
|
assert response.json()["detail"] == "Invalid signature"
|
||||||
|
mock_redis["enqueue"].assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_inbound_email_returns_405(client):
|
||||||
|
"""GET /inbound/email is not allowed."""
|
||||||
|
response = client.get("/inbound/email")
|
||||||
|
assert response.status_code == 405
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { authClient } from '../lib/auth-client.ts'
|
import { authClient } from '../lib/auth-client.ts'
|
||||||
import { useAuthStore } from '../stores/auth.ts'
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
@@ -9,6 +10,26 @@ export function Settings() {
|
|||||||
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { theme, setTheme } = useThemeStore()
|
const { theme, setTheme } = useThemeStore()
|
||||||
|
const [emailInAddress, setEmailInAddress] = useState<string | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session?.user) return
|
||||||
|
fetch('/api/v1/me/email-in-address', {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => setEmailInAddress(data.email_address))
|
||||||
|
.catch(() => setEmailInAddress(null))
|
||||||
|
}, [session])
|
||||||
|
|
||||||
|
async function handleCopyEmail() {
|
||||||
|
if (emailInAddress) {
|
||||||
|
await navigator.clipboard.writeText(emailInAddress)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = session?.user
|
const user = session?.user
|
||||||
const connectedStores: string[] = []
|
const connectedStores: string[] = []
|
||||||
@@ -113,6 +134,30 @@ export function Settings() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Receipt Email section */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-gray-500">Receipt Email</h2>
|
||||||
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<p className="mb-2 text-sm text-gray-600">
|
||||||
|
Forward your digital receipt emails to this address:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 rounded-lg bg-gray-100 px-3 py-2 text-sm font-mono text-gray-800 truncate">
|
||||||
|
{emailInAddress ?? 'Loading...'}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyEmail}
|
||||||
|
className="rounded-lg bg-brand-blue px-3 py-2 text-sm font-medium text-white hover:bg-brand-blue/90 transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-400">
|
||||||
|
Supports Meijer, Kroger, and Target receipt emails.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user