diff --git a/README.md b/README.md index a9b1bef..265def6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # Install all dependencies uv sync --all-extras + +# Copy the example env file and fill in values +cp apps/kitchen_mate/.env.example apps/kitchen_mate/.env ``` ### Running the CLI @@ -46,35 +49,59 @@ uv run --directory packages/recipe_clipper recipe-clipper ### Running the Backend ```bash +# Run database migrations +uv run --directory apps/kitchen_mate alembic upgrade head + # Development server uv run --directory apps/kitchen_mate uvicorn kitchen_mate.main:app --reload -# Using Docker +# Using Docker (runs migrations automatically) docker compose up --build ``` +By default, migrations create `kitchenmate.db` in the current directory. Set `CACHE_DB_PATH` to override the location. + +### Running the Frontend + +```bash +# Install dependencies (first time only) +npm install --prefix apps/kitchen_mate/frontend + +# Start development server (port 5173) +npm run dev --prefix apps/kitchen_mate/frontend +``` + +The Vite dev server proxies API requests to the backend at `http://localhost:8000`, so the backend must be running. + ## Deployment Modes Kitchen Mate supports two deployment modes: ### Single-Tenant (Self-Hosted) -Run your own instance with no authentication required. All features are available to anyone with access. +Run your own instance with no authentication required. All features are available to anyone with access. No Supabase configuration needed. ```bash -# No Supabase configuration needed - just run the app -docker compose up --build +# Run database migrations +uv run --directory apps/kitchen_mate alembic upgrade head + +# Start backend (optional: set ANTHROPIC_API_KEY for LLM-based extraction) +uv run --directory apps/kitchen_mate uvicorn kitchen_mate.main:app --reload + +# Start frontend (in a separate terminal) +npm run dev --prefix apps/kitchen_mate/frontend ``` ### Multi-Tenant (SaaS) Deploy as a multi-tenant service with Supabase authentication. Public features (clip, export) work for everyone; user-specific features require sign-in. +Set the following in `apps/kitchen_mate/.env` (see `.env.example`): + ```bash -# Set Supabase environment variables -export SUPABASE_JWT_SECRET="your-jwt-secret" -export VITE_SUPABASE_URL="https://your-project.supabase.co" -export VITE_SUPABASE_ANON_KEY="your-anon-key" +SUPABASE_JWT_SECRET="your-jwt-secret" +SUPABASE_URL="https://your-project.supabase.co" +SUPABASE_ANON_KEY="your-anon-key" ``` The mode is determined automatically by whether `SUPABASE_JWT_SECRET` is set. diff --git a/apps/kitchen_mate/.env.example b/apps/kitchen_mate/.env.example index 9163445..d8f8671 100644 --- a/apps/kitchen_mate/.env.example +++ b/apps/kitchen_mate/.env.example @@ -1,18 +1,27 @@ -# Kitchen Mate Environment Configuration -# Copy this file to .env and fill in your values +# LLM-based recipe extraction (optional in single-tenant mode) +ANTHROPIC_API_KEY= -# LLM fallback for unsupported recipe sites (optional) -ANTHROPIC_API_KEY=your-api-key-here +# Multi-tenant mode (leave unset for single-tenant/self-hosted) +SUPABASE_JWT_SECRET= +SUPABASE_URL= +SUPABASE_ANON_KEY= -# Pro user IDs for tier-based authorization (comma-separated Supabase user IDs) -# Users in this list get Pro tier with access to LLM features -# In single-tenant mode (no Supabase), all users automatically get Pro tier -# PRO_USER_IDS=uuid-1,uuid-2,uuid-3 +# Pro tier access (comma-separated Supabase user IDs) +PRO_USER_IDS= -# CORS origins (comma-separated, optional) -# CORS_ORIGINS=http://localhost:5173,https://yourdomain.com +# CORS (comma-separated origins) +CORS_ORIGINS=http://localhost:5173 -# Multi-tenant mode: Uncomment these to enable Supabase authentication -# SUPABASE_URL is used by both backend (JWKS verification) and frontend (auth client) -# SUPABASE_URL=https://your-project.supabase.co -# SUPABASE_ANON_KEY=your-anon-key +# Database +CACHE_DB_PATH=kitchenmate.db + +# File storage ("local" or "s3") +STORAGE_BACKEND=local +STORAGE_LOCAL_PATH=uploads + +# S3 storage (required when STORAGE_BACKEND=s3) +S3_BUCKET= +S3_ACCESS_KEY_ID= +S3_SECRET_ACCESS_KEY= +S3_REGION=us-east-1 +S3_ENDPOINT_URL= diff --git a/apps/kitchen_mate/Dockerfile b/apps/kitchen_mate/Dockerfile index 2b0bf6a..d99ad1d 100644 --- a/apps/kitchen_mate/Dockerfile +++ b/apps/kitchen_mate/Dockerfile @@ -63,6 +63,8 @@ RUN uv sync --frozen --no-dev # Default port (Render sets PORT environment variable) ENV PORT=8000 +# Default local storage path to persistent disk; overridden in production by STORAGE_BACKEND=s3 +ENV STORAGE_LOCAL_PATH=/data/uploads EXPOSE $PORT # Run migrations and start the application (shell form to expand $PORT) diff --git a/apps/kitchen_mate/alembic/versions/a1b2c3d4e5f6_add_file_keys_to_user_recipes.py b/apps/kitchen_mate/alembic/versions/a1b2c3d4e5f6_add_file_keys_to_user_recipes.py new file mode 100644 index 0000000..47057c4 --- /dev/null +++ b/apps/kitchen_mate/alembic/versions/a1b2c3d4e5f6_add_file_keys_to_user_recipes.py @@ -0,0 +1,29 @@ +"""Add source_file_key and thumbnail_key to user_recipes + +Revision ID: a1b2c3d4e5f6 +Revises: ede6c69f37ad +Create Date: 2026-04-11 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, Sequence[str], None] = "ede6c69f37ad" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Add file storage key columns to user_recipes.""" + op.add_column("user_recipes", sa.Column("source_file_key", sa.Text(), nullable=True)) + op.add_column("user_recipes", sa.Column("thumbnail_key", sa.Text(), nullable=True)) + + +def downgrade() -> None: + """Remove file storage key columns from user_recipes.""" + op.drop_column("user_recipes", "thumbnail_key") + op.drop_column("user_recipes", "source_file_key") diff --git a/apps/kitchen_mate/frontend/package-lock.json b/apps/kitchen_mate/frontend/package-lock.json index f746a81..499d41a 100644 --- a/apps/kitchen_mate/frontend/package-lock.json +++ b/apps/kitchen_mate/frontend/package-lock.json @@ -69,7 +69,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -978,9 +977,9 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -992,9 +991,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -1006,9 +1005,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -1020,9 +1019,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -1034,9 +1033,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -1048,9 +1047,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -1062,13 +1061,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1076,13 +1078,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1090,13 +1095,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1104,13 +1112,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1118,13 +1129,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1132,13 +1146,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1146,13 +1163,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1160,13 +1180,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1174,13 +1197,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1188,13 +1214,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1202,13 +1231,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1216,35 +1248,43 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], @@ -1256,9 +1296,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], @@ -1270,9 +1310,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -1284,9 +1324,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -1298,9 +1338,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], @@ -1312,9 +1352,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -1485,7 +1525,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1554,7 +1593,6 @@ "integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", @@ -1699,9 +1737,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -1709,13 +1747,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1805,7 +1843,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1824,9 +1861,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1876,9 +1913,9 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -1966,9 +2003,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2007,7 +2044,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2310,7 +2346,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2621,9 +2656,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2852,7 +2887,6 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -3028,9 +3062,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3041,9 +3075,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3224,12 +3258,11 @@ "dev": true }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3274,7 +3307,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3456,7 +3488,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3468,7 +3499,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3546,9 +3576,9 @@ } }, "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3599,10 +3629,11 @@ } }, "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "1.0.8" }, @@ -3614,31 +3645,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -3909,7 +3940,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3999,7 +4029,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -4126,7 +4155,6 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/apps/kitchen_mate/frontend/src/api/recipes.ts b/apps/kitchen_mate/frontend/src/api/recipes.ts index 59989ce..2e6bcae 100644 --- a/apps/kitchen_mate/frontend/src/api/recipes.ts +++ b/apps/kitchen_mate/frontend/src/api/recipes.ts @@ -4,6 +4,7 @@ import { Recipe, SaveRecipeRequest, SaveRecipeResponse, + ThumbnailUploadResponse, UpdateUserRecipeRequest, UserRecipe, getErrorMessage, @@ -178,3 +179,80 @@ export async function deleteUserRecipe(recipeId: string): Promise { throw new RecipeError(message, response.status); } } + +/** + * Save a recipe extracted from an uploaded file (atomic file + recipe save). + */ +export async function saveRecipeFromUpload( + file: File, + recipe: Recipe, + parsingMethod: string, + tags?: string[], + notes?: string +): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("recipe_json", JSON.stringify(recipe)); + formData.append("parsing_method", parsingMethod); + if (tags && tags.length > 0) { + formData.append("tags_json", JSON.stringify(tags)); + } + if (notes) { + formData.append("notes", notes); + } + + const response = await fetch(`${API_BASE}/me/recipes/from-upload`, { + method: "POST", + body: formData, + credentials: "include", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + const message = getErrorMessage(error.detail, "Failed to save recipe"); + throw new RecipeError(message, response.status); + } + + return response.json(); +} + +/** + * Upload or replace the thumbnail for a saved recipe. + */ +export async function uploadRecipeThumbnail( + recipeId: string, + file: File +): Promise { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch(`${API_BASE}/me/recipes/${recipeId}/thumbnail`, { + method: "POST", + body: formData, + credentials: "include", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + const message = getErrorMessage(error.detail, "Failed to upload thumbnail"); + throw new RecipeError(message, response.status); + } + + return response.json(); +} + +/** + * Delete the thumbnail for a saved recipe. + */ +export async function deleteRecipeThumbnail(recipeId: string): Promise { + const response = await fetch(`${API_BASE}/me/recipes/${recipeId}/thumbnail`, { + method: "DELETE", + credentials: "include", + }); + + if (!response.ok) { + const error: ApiError = await response.json(); + const message = getErrorMessage(error.detail, "Failed to delete thumbnail"); + throw new RecipeError(message, response.status); + } +} diff --git a/apps/kitchen_mate/frontend/src/components/AddFromUploadPage.tsx b/apps/kitchen_mate/frontend/src/components/AddFromUploadPage.tsx index e8961ba..4077f9a 100644 --- a/apps/kitchen_mate/frontend/src/components/AddFromUploadPage.tsx +++ b/apps/kitchen_mate/frontend/src/components/AddFromUploadPage.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; import { Recipe } from "../types/recipe"; import { uploadRecipe, ClipError } from "../api/clip"; -import { saveRecipe, RecipeError } from "../api/recipes"; +import { saveRecipeFromUpload, RecipeError } from "../api/recipes"; import { FileDropZone } from "./FileDropZone"; import { RecipeCard } from "./RecipeCard"; import { LoadingSpinner } from "./LoadingSpinner"; @@ -13,8 +13,8 @@ import { useIsPro } from "../hooks/usePermission"; type PageState = | { status: "idle" } | { status: "extracting"; filename: string } - | { status: "extracted"; filename: string; recipe: Recipe; parsingMethod: string } - | { status: "saving"; filename: string; recipe: Recipe; parsingMethod: string } + | { status: "extracted"; file: File; filename: string; recipe: Recipe; parsingMethod: string } + | { status: "saving"; file: File; filename: string; recipe: Recipe; parsingMethod: string } | { status: "saved"; filename: string; recipe: Recipe; recipeId: string } | { status: "error"; filename: string; message: string; errorType: ErrorType }; @@ -31,6 +31,7 @@ export function AddFromUploadPage() { const result = await uploadRecipe(file); setState({ status: "extracted", + file, filename: file.name, recipe: result.recipe, parsingMethod: result.parsing_method, @@ -57,17 +58,18 @@ export function AddFromUploadPage() { setState({ status: "saving", + file: state.file, filename: state.filename, recipe: state.recipe, parsingMethod: state.parsingMethod, }); try { - const result = await saveRecipe({ - sourceType: "upload", - recipe: state.recipe, - parsingMethod: state.parsingMethod, - }); + const result = await saveRecipeFromUpload( + state.file, + state.recipe, + state.parsingMethod, + ); setState({ status: "saved", filename: state.filename, diff --git a/apps/kitchen_mate/frontend/src/components/AddManualPage.tsx b/apps/kitchen_mate/frontend/src/components/AddManualPage.tsx index 4c43adc..67a5694 100644 --- a/apps/kitchen_mate/frontend/src/components/AddManualPage.tsx +++ b/apps/kitchen_mate/frontend/src/components/AddManualPage.tsx @@ -1,7 +1,7 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { Recipe } from "../types/recipe"; -import { saveRecipe, RecipeError } from "../api/recipes"; +import { saveRecipe, uploadRecipeThumbnail, RecipeError } from "../api/recipes"; import { RecipeEditor } from "./RecipeEditor"; import { LoadingSpinner } from "./LoadingSpinner"; import { ErrorMessage } from "./ErrorMessage"; @@ -20,10 +20,28 @@ export function AddManualPage() { const [tags, setTags] = useState([]); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + const [imageFile, setImageFile] = useState(null); + const [imagePreviewUrl, setImagePreviewUrl] = useState(null); + const fileInputRef = useRef(null); const navigate = useNavigate(); const { isAuthorized } = useRequireAuth(); const { isAuthEnabled } = useAuthContext(); + const handleImageChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); + setImageFile(file); + setImagePreviewUrl(URL.createObjectURL(file)); + }; + + const handleRemoveImage = () => { + if (imagePreviewUrl) URL.revokeObjectURL(imagePreviewUrl); + setImageFile(null); + setImagePreviewUrl(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + const handleSave = async () => { // Validate required fields if (!recipe.title.trim()) { @@ -66,6 +84,10 @@ export function AddManualPage() { notes: notes.trim() || undefined, }); + if (imageFile) { + await uploadRecipeThumbnail(result.user_recipe_id, imageFile); + } + navigate(`/recipes/${result.user_recipe_id}`); } catch (err) { const message = @@ -145,6 +167,49 @@ export function AddManualPage() { )} +
+ + + {imagePreviewUrl ? ( +
+ Recipe preview + +
+ ) : ( + + )} +
+
(null); + const [isThumbnailUploading, setIsThumbnailUploading] = useState(false); + const thumbnailInputRef = useRef(null); + // Edit state const [editedRecipe, setEditedRecipe] = useState(null); const [editedNotes, setEditedNotes] = useState(""); @@ -38,6 +45,7 @@ export function SavedRecipeView() { setEditedRecipe(data.recipe); setEditedNotes(data.notes || ""); setEditedTags(data.tags || []); + setThumbnailUrl(data.recipe.image || null); }) .catch((err) => { const message = @@ -95,6 +103,34 @@ export function SavedRecipeView() { setIsEditing(false); }; + const handleThumbnailFileChange = async (e: React.ChangeEvent) => { + if (!id || !e.target.files || e.target.files.length === 0) return; + const file = e.target.files[0]; + e.target.value = ""; + setIsThumbnailUploading(true); + try { + const result = await uploadRecipeThumbnail(id, file); + setThumbnailUrl(result.image_url); + } catch (err) { + setError(err instanceof RecipeError ? err.message : "Failed to upload thumbnail"); + } finally { + setIsThumbnailUploading(false); + } + }; + + const handleDeleteThumbnail = async () => { + if (!id) return; + setIsThumbnailUploading(true); + try { + await deleteRecipeThumbnail(id); + setThumbnailUrl(null); + } catch (err) { + setError(err instanceof RecipeError ? err.message : "Failed to delete thumbnail"); + } finally { + setIsThumbnailUploading(false); + } + }; + const formatTime = (minutes: number): string => { if (minutes < 60) return `${minutes} min`; const hours = Math.floor(minutes / 60); @@ -144,9 +180,9 @@ export function SavedRecipeView() { if (isEditing) { return (
- {recipe.image && ( + {thumbnailUrl && ( {recipe.title} @@ -172,13 +208,64 @@ export function SavedRecipeView() { // View mode return (
- {/* Image */} - {recipe.image && ( - {recipe.title} + {/* Hidden file input for thumbnail */} + + + {/* Image / Thumbnail area */} + {thumbnailUrl ? ( +
+ {recipe.title} + {!isThumbnailUploading && ( +
+ + +
+ )} + {isThumbnailUploading && ( +
+ Updating... +
+ )} +
+ ) : ( +
+ +
)}
@@ -263,6 +350,23 @@ export function SavedRecipeView() {
)} + {/* Source file link for uploaded recipes */} + {userRecipe.source_file_url && ( + + )} + {/* Metadata */} {recipe.metadata && (
diff --git a/apps/kitchen_mate/frontend/src/types/recipe.ts b/apps/kitchen_mate/frontend/src/types/recipe.ts index 9f92ab3..6564359 100644 --- a/apps/kitchen_mate/frontend/src/types/recipe.ts +++ b/apps/kitchen_mate/frontend/src/types/recipe.ts @@ -130,6 +130,7 @@ export interface UserRecipeSummary { image_url: string | null; is_modified: boolean; tags: string[] | null; + source_file_url?: string | null; created_at: string; updated_at: string; } @@ -154,10 +155,15 @@ export interface UserRecipe { tags: string[] | null; recipe: Recipe; lineage: RecipeLineage; + source_file_url?: string | null; created_at: string; updated_at: string; } +export interface ThumbnailUploadResponse { + image_url: string; +} + export interface UpdateUserRecipeRequest { recipe?: Recipe; notes?: string; diff --git a/apps/kitchen_mate/pyproject.toml b/apps/kitchen_mate/pyproject.toml index 8c835b2..6269d44 100644 --- a/apps/kitchen_mate/pyproject.toml +++ b/apps/kitchen_mate/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "sqlalchemy[asyncio]>=2.0.0", "aiosqlite>=0.19.0", "alembic>=1.13.0", + "boto3>=1.42.91", ] [project.optional-dependencies] diff --git a/apps/kitchen_mate/src/kitchen_mate/auth.py b/apps/kitchen_mate/src/kitchen_mate/auth.py index 6320dcc..5ac5a9b 100644 --- a/apps/kitchen_mate/src/kitchen_mate/auth.py +++ b/apps/kitchen_mate/src/kitchen_mate/auth.py @@ -58,7 +58,7 @@ def verify_jwt_token(token: str, settings: Settings) -> dict: Raises: HTTPException: If token is invalid or verification fails """ - if not settings.supabase_jwt_secret and not settings.supabase_url: + if not settings.supabase.jwt_secret and not settings.supabase.url: raise HTTPException(status_code=500, detail="Authentication not configured") try: @@ -67,9 +67,9 @@ def verify_jwt_token(token: str, settings: Settings) -> dict: alg = unverified_header.get("alg", "unknown") logger.info("JWT algorithm: %s", alg) - if alg == "ES256" and settings.supabase_url: + if alg == "ES256" and settings.supabase.url: # Use JWKS for ES256 - jwks_client = get_jwks_client(settings.supabase_url) + jwks_client = get_jwks_client(settings.supabase.url) signing_key = jwks_client.get_signing_key_from_jwt(token) payload = jwt.decode( token, @@ -77,11 +77,11 @@ def verify_jwt_token(token: str, settings: Settings) -> dict: algorithms=["ES256"], audience="authenticated", ) - elif settings.supabase_jwt_secret: + elif settings.supabase.jwt_secret: # Use JWT secret for HS256 payload = jwt.decode( token, - settings.supabase_jwt_secret, + settings.supabase.jwt_secret, algorithms=["HS256"], audience="authenticated", ) diff --git a/apps/kitchen_mate/src/kitchen_mate/config.py b/apps/kitchen_mate/src/kitchen_mate/config.py index 93e1da3..72e121c 100644 --- a/apps/kitchen_mate/src/kitchen_mate/config.py +++ b/apps/kitchen_mate/src/kitchen_mate/config.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any -from pydantic import AliasChoices, Field, model_validator +from pydantic import AliasChoices, BaseModel, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Path to .env file relative to this config file (apps/kitchen_mate/.env) @@ -23,6 +23,36 @@ def _parse_user_ids(value: str | set[str] | None) -> set[str]: return {uid.strip() for uid in value.split(",") if uid.strip()} +class AnthropicConfig(BaseModel): + api_key: str | None = None + default_timeout: int = 10 + + +class SupabaseConfig(BaseModel): + jwt_secret: str | None = None # For HS256 verification (legacy) + url: str | None = None # For ES256 JWKS verification + + +class DatabaseConfig(BaseModel): + path: str = "kitchenmate.db" + enabled: bool = True + + +class S3Config(BaseModel): + bucket: str | None = None + access_key_id: str | None = None + secret_access_key: str | None = None + region: str = "us-east-1" + endpoint_url: str | None = None # MinIO, R2, B2 override + + +class StorageConfig(BaseModel): + backend: str = "local" # "local" | "s3" + local_path: str = "uploads" + public_base_url: str | None = None # Required for S3 public buckets; auto-set for local + s3: S3Config = Field(default_factory=S3Config) + + class Settings(BaseSettings): """Application settings loaded from environment variables.""" @@ -53,11 +83,20 @@ class Settings(BaseSettings): cache_db_path: str = "kitchenmate.db" cache_enabled: bool = True + # Storage configuration + storage_backend: str = "local" # "local" | "s3" + storage_local_path: str = "uploads" + storage_public_base_url: str | None = None # Required for S3 public buckets; auto-set for local + s3_bucket: str | None = None + s3_endpoint_url: str | None = None # MinIO, R2, B2 override + s3_access_key_id: str | None = None + s3_secret_access_key: str | None = None + s3_region: str = "us-east-1" + @model_validator(mode="before") @classmethod def handle_pro_user_ids(cls, data: dict[str, Any]) -> dict[str, Any]: """Convert pro_user_ids to pro_user_ids_str if needed.""" - # Check for pro_user_ids in various forms pro_ids = data.pop("pro_user_ids", None) or data.pop("PRO_USER_IDS", None) if pro_ids is not None: if isinstance(pro_ids, set): @@ -66,6 +105,52 @@ def handle_pro_user_ids(cls, data: dict[str, Any]) -> dict[str, Any]: data["pro_user_ids_str"] = pro_ids return data + @model_validator(mode="after") + def validate_s3_settings(self) -> "Settings": + """Validate that required S3 settings are present when using S3 backend.""" + if self.storage_backend == "s3": + missing = [ + field + for field, value in [ + ("s3_bucket", self.s3_bucket), + ("s3_access_key_id", self.s3_access_key_id), + ("s3_secret_access_key", self.s3_secret_access_key), + ] + if not value + ] + if missing: + raise ValueError(f"S3 storage backend requires: {', '.join(missing)}") + return self + + # --- Grouped sub-config accessors --- + + @property + def anthropic(self) -> AnthropicConfig: + return AnthropicConfig(api_key=self.anthropic_api_key, default_timeout=self.default_timeout) + + @property + def supabase(self) -> SupabaseConfig: + return SupabaseConfig(jwt_secret=self.supabase_jwt_secret, url=self.supabase_url) + + @property + def database(self) -> DatabaseConfig: + return DatabaseConfig(path=self.cache_db_path, enabled=self.cache_enabled) + + @property + def storage(self) -> StorageConfig: + return StorageConfig( + backend=self.storage_backend, + local_path=self.storage_local_path, + public_base_url=self.storage_public_base_url, + s3=S3Config( + bucket=self.s3_bucket, + access_key_id=self.s3_access_key_id, + secret_access_key=self.s3_secret_access_key, + region=self.s3_region, + endpoint_url=self.s3_endpoint_url, + ), + ) + @property def pro_user_ids(self) -> set[str]: """Get pro user IDs as a set.""" diff --git a/apps/kitchen_mate/src/kitchen_mate/database/__init__.py b/apps/kitchen_mate/src/kitchen_mate/database/__init__.py index 0eea962..5235ede 100644 --- a/apps/kitchen_mate/src/kitchen_mate/database/__init__.py +++ b/apps/kitchen_mate/src/kitchen_mate/database/__init__.py @@ -25,6 +25,7 @@ save_user_recipe, store_recipe, update_recipe, + update_recipe_thumbnail_key, update_user_recipe, ) @@ -54,5 +55,6 @@ "get_user_recipe_with_lineage", "save_user_recipe", "update_user_recipe", + "update_recipe_thumbnail_key", "delete_user_recipe", ] diff --git a/apps/kitchen_mate/src/kitchen_mate/database/models.py b/apps/kitchen_mate/src/kitchen_mate/database/models.py index db23729..c2f929c 100644 --- a/apps/kitchen_mate/src/kitchen_mate/database/models.py +++ b/apps/kitchen_mate/src/kitchen_mate/database/models.py @@ -51,6 +51,8 @@ class UserRecipeModel(Base): is_modified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) notes: Mapped[str | None] = mapped_column(Text, nullable=True) tags: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON array + source_file_key: Mapped[str | None] = mapped_column(Text, nullable=True) + thumbnail_key: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/apps/kitchen_mate/src/kitchen_mate/database/repositories.py b/apps/kitchen_mate/src/kitchen_mate/database/repositories.py index 2b134ab..9836593 100644 --- a/apps/kitchen_mate/src/kitchen_mate/database/repositories.py +++ b/apps/kitchen_mate/src/kitchen_mate/database/repositories.py @@ -47,6 +47,8 @@ class UserRecipe(BaseModel): is_modified: bool notes: str | None tags: list[str] | None + source_file_key: str | None = None + thumbnail_key: str | None = None created_at: datetime updated_at: datetime deleted_at: datetime | None = None @@ -61,6 +63,8 @@ class UserRecipeSummary(BaseModel): image_url: str | None is_modified: bool tags: list[str] | None + source_file_key: str | None = None + thumbnail_key: str | None = None created_at: datetime updated_at: datetime @@ -112,6 +116,8 @@ def _user_recipe_model_to_schema(model: UserRecipeModel) -> UserRecipe: is_modified=model.is_modified, notes=model.notes, tags=tags_data, + source_file_key=model.source_file_key, + thumbnail_key=model.thumbnail_key, created_at=model.created_at, updated_at=model.updated_at, deleted_at=model.deleted_at, @@ -371,6 +377,8 @@ async def get_user_recipes( image_url=recipe_data.get("image"), is_modified=user_recipe.is_modified, tags=tags_data, + source_file_key=user_recipe.source_file_key, + thumbnail_key=user_recipe.thumbnail_key, created_at=user_recipe.created_at, updated_at=user_recipe.updated_at, ) @@ -444,6 +452,9 @@ async def save_user_recipe( recipe_data: Recipe, tags: list[str] | None = None, notes: str | None = None, + user_recipe_id: str | None = None, + source_file_key: str | None = None, + thumbnail_key: str | None = None, ) -> tuple[UserRecipe, bool]: """Save a recipe to user's collection. @@ -455,11 +466,14 @@ async def save_user_recipe( recipe_data: The recipe data to copy tags: Optional list of tags notes: Optional notes + user_recipe_id: Optional pre-generated UUID for the user recipe row + source_file_key: Optional storage key for the uploaded source file + thumbnail_key: Optional storage key for the recipe thumbnail Returns: Tuple of (UserRecipe, is_new) - is_new is False if recipe was already saved or restored """ - user_recipe_id = str(uuid.uuid4()) + user_recipe_id = user_recipe_id or str(uuid.uuid4()) recipe_json = recipe_data.model_dump_json() tags_json = json.dumps(tags) if tags else None now = datetime.now() @@ -483,7 +497,15 @@ async def save_user_recipe( existing.tags = tags_json if notes: existing.notes = notes - # Commit happens via context manager + + if source_file_key is not None: + existing.source_file_key = source_file_key + + if thumbnail_key is not None: + existing.thumbnail_key = thumbnail_key + + if source_file_key is not None or thumbnail_key is not None: + existing.updated_at = now # Return the existing recipe return _user_recipe_model_to_schema(existing), False @@ -497,6 +519,8 @@ async def save_user_recipe( is_modified=False, notes=notes, tags=tags_json, + source_file_key=source_file_key, + thumbnail_key=thumbnail_key, created_at=now, updated_at=now, ) @@ -513,6 +537,8 @@ async def save_user_recipe( is_modified=False, notes=notes, tags=tags_list, + source_file_key=source_file_key, + thumbnail_key=thumbnail_key, created_at=now, updated_at=now, ), @@ -572,6 +598,33 @@ async def update_user_recipe( return _user_recipe_model_to_schema(existing) +async def update_recipe_thumbnail_key( + user_recipe_id: str, user_id: str, thumbnail_key: str | None +) -> bool: + """Update the thumbnail_key for a user recipe. + + Args: + user_recipe_id: The user recipe ID + user_id: The user's ID (ownership check) + thumbnail_key: New thumbnail key, or None to clear + + Returns: + True if updated, False if not found or not owned by user + """ + now = datetime.now() + + async with get_session() as session: + stmt = ( + update(UserRecipeModel) + .where(UserRecipeModel.id == user_recipe_id) + .where(UserRecipeModel.user_id == user_id) + .where(UserRecipeModel.deleted_at.is_(None)) + .values(thumbnail_key=thumbnail_key, updated_at=now) + ) + result = await session.execute(stmt) + return result.rowcount > 0 + + async def delete_user_recipe(user_id: str, recipe_id: str) -> bool: """Soft delete a user's recipe. diff --git a/apps/kitchen_mate/src/kitchen_mate/main.py b/apps/kitchen_mate/src/kitchen_mate/main.py index 4ee2833..4173adc 100644 --- a/apps/kitchen_mate/src/kitchen_mate/main.py +++ b/apps/kitchen_mate/src/kitchen_mate/main.py @@ -14,7 +14,7 @@ from kitchen_mate.config import get_settings from kitchen_mate.database import close_database, init_database -from kitchen_mate.routes import auth, clip, convert, me +from kitchen_mate.routes import auth, clip, convert, files, me # Get settings at module load for CORS configuration @@ -28,13 +28,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # Get settings at startup (allows dependency injection override for tests) settings = get_settings() - if settings.cache_enabled: - await init_database(settings.cache_db_path) + if settings.database.enabled: + await init_database(settings.database.path) yield # Cleanup on shutdown - if settings.cache_enabled: + if settings.database.enabled: await close_database() @@ -59,6 +59,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.include_router(clip.router, prefix="/api") app.include_router(convert.router, prefix="/api") app.include_router(me.router, prefix="/api") +app.include_router(files.router, prefix="/api") @app.get("/health") diff --git a/apps/kitchen_mate/src/kitchen_mate/routes/clip.py b/apps/kitchen_mate/src/kitchen_mate/routes/clip.py index e4fa0dc..1aead5d 100644 --- a/apps/kitchen_mate/src/kitchen_mate/routes/clip.py +++ b/apps/kitchen_mate/src/kitchen_mate/routes/clip.py @@ -57,7 +57,7 @@ async def clip_recipe( try: # Try cache first (unless force_refresh) - if settings.cache_enabled and not clip_request.force_refresh: + if settings.database.enabled and not clip_request.force_refresh: cached = await _get_from_cache(url, clip_request.force_llm) if cached: return ClipResponse(recipe=cached.recipe, cached=True) @@ -67,14 +67,14 @@ async def clip_recipe( url=url, timeout=clip_request.timeout, use_llm_fallback=clip_request.use_llm_fallback, - api_key=settings.anthropic_api_key, + api_key=settings.anthropic.api_key, llm_permitted=can_use_ai, force_llm=clip_request.force_llm, - check_content_changed=clip_request.force_refresh and settings.cache_enabled, + check_content_changed=clip_request.force_refresh and settings.database.enabled, ) # Cache the result - if settings.cache_enabled: + if settings.database.enabled: await _save_to_cache(url, recipe, content_hash, parsed_with) return ClipResponse(recipe=recipe, cached=False, content_changed=content_changed) @@ -129,7 +129,7 @@ async def clip_recipe_from_upload( - Single-tenant: Available to all (pro tier by default) - Multi-tenant: Requires pro subscription """ - if not settings.anthropic_api_key: + if not settings.anthropic.api_key: raise HTTPException( status_code=503, detail="LLM extraction is not configured.", @@ -157,7 +157,7 @@ async def clip_recipe_from_upload( recipe = await asyncio.to_thread( parse_recipe_from_image, temp_path, - api_key=settings.anthropic_api_key, + api_key=settings.anthropic.api_key, ) parsing_method = Parser.llm_image else: @@ -166,7 +166,7 @@ async def clip_recipe_from_upload( recipe = await asyncio.to_thread( parse_recipe_from_document, temp_path, - api_key=settings.anthropic_api_key, + api_key=settings.anthropic.api_key, ) parsing_method = Parser.llm_document diff --git a/apps/kitchen_mate/src/kitchen_mate/routes/files.py b/apps/kitchen_mate/src/kitchen_mate/routes/files.py new file mode 100644 index 0000000..3cad796 --- /dev/null +++ b/apps/kitchen_mate/src/kitchen_mate/routes/files.py @@ -0,0 +1,47 @@ +"""Authenticated file serving route for local storage backend.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse + +from kitchen_mate.auth import User, get_user +from kitchen_mate.storage import get_storage +from kitchen_mate.storage.backends import LocalStorageBackend, StorageBackend + +router = APIRouter() + + +@router.get("/files/{file_key:path}") +async def serve_file( + file_key: str, + user: Annotated[User, Depends(get_user)], + storage: Annotated[StorageBackend, Depends(get_storage)], +) -> FileResponse: + """Serve a stored file for the authenticated user. + + Only serves files owned by the requesting user (key must start with users/{user.id}/). + Only applicable for local storage backend; S3 backends redirect via URL. + """ + if not isinstance(storage, LocalStorageBackend): + raise HTTPException(status_code=404, detail="File not found") + + # Authorization: key must belong to this user + expected_prefix = f"users/{user.id}/" + if not file_key.startswith(expected_prefix): + raise HTTPException(status_code=403, detail="Access denied") + + # Resolve and validate the path (prevents traversal) + try: + base_resolved = storage.base_path.resolve() + resolved = (base_resolved / file_key).resolve() + resolved.relative_to(base_resolved) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid file key") + + if not resolved.exists() or not resolved.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + return FileResponse(path=resolved) diff --git a/apps/kitchen_mate/src/kitchen_mate/routes/me.py b/apps/kitchen_mate/src/kitchen_mate/routes/me.py index 5d7f5a9..6d71987 100644 --- a/apps/kitchen_mate/src/kitchen_mate/routes/me.py +++ b/apps/kitchen_mate/src/kitchen_mate/routes/me.py @@ -3,9 +3,11 @@ from __future__ import annotations import hashlib +import logging +import uuid from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from recipe_clipper.exceptions import ( LLMError, @@ -14,6 +16,7 @@ RecipeNotFoundError, RecipeParsingError, ) +from recipe_clipper.models import Recipe from kitchen_mate.auth import User, get_user from kitchen_mate.authorization import ( @@ -22,18 +25,22 @@ UpgradeRequiredError, check_permission_soft, get_tier_info, + require_permission, ) from kitchen_mate.config import Settings, get_settings from kitchen_mate.database import ( delete_user_recipe, get_cached_recipe, + get_user_recipe, get_user_recipe_with_lineage, get_user_recipes, save_user_recipe, store_recipe, + update_recipe_thumbnail_key, update_user_recipe, ) from kitchen_mate.extraction import LLMNotAllowedError, extract_recipe +from kitchen_mate.files import FileValidationError, process_upload from kitchen_mate.schemas import ( GetUserRecipeResponse, ListUserRecipesResponse, @@ -42,15 +49,30 @@ SaveRecipeRequest, SaveRecipeResponse, SourceType, + ThumbnailUploadResponse, UpdateUserRecipeRequest, UpdateUserRecipeResponse, UserRecipeSummaryResponse, ) +from kitchen_mate.storage import StorageBackend, get_storage + +logger = logging.getLogger(__name__) router = APIRouter() +def _resolve_image_url( + recipe_image_url: str | None, + thumbnail_key: str | None, + storage: StorageBackend, +) -> str | None: + """Return the best available image URL for a recipe.""" + if thumbnail_key: + return storage.get_url(thumbnail_key) + return recipe_image_url + + @router.post("/me/recipes", status_code=201) async def save_recipe( save_request: SaveRecipeRequest, @@ -178,7 +200,7 @@ async def _save_web_recipe( url=url, timeout=save_request.timeout, use_llm_fallback=save_request.use_llm_fallback, - api_key=settings.anthropic_api_key, + api_key=settings.anthropic.api_key, llm_permitted=can_use_ai, ) @@ -215,9 +237,99 @@ async def _save_web_recipe( raise HTTPException(status_code=500, detail=str(error)) from error +@router.post("/me/recipes/from-upload", status_code=201) +async def save_recipe_from_upload( + file: Annotated[UploadFile, File(description="The uploaded recipe file")], + recipe_json: Annotated[str, Form(description="Recipe JSON string")], + parsing_method: Annotated[str, Form(description="Parsing method used")], + user: Annotated[User, Depends(require_permission(Permission.CLIP_UPLOAD))], + storage: Annotated[StorageBackend, Depends(get_storage)], + tags_json: Annotated[str | None, Form(description="Tags JSON array")] = None, + notes: Annotated[str | None, Form(description="Personal notes")] = None, +) -> SaveRecipeResponse: + """Save a recipe that was extracted from an uploaded file. + + Atomically stores the file and saves the recipe in a single request. + The file is only stored if the recipe save succeeds. + """ + import json as _json + + try: + recipe = Recipe.model_validate_json(recipe_json) + except Exception as exc: + raise HTTPException(status_code=422, detail=f"Invalid recipe JSON: {exc}") from exc + + tags: list[str] | None = None + if tags_json: + try: + tags = _json.loads(tags_json) + except Exception as exc: + raise HTTPException(status_code=422, detail=f"Invalid tags JSON: {exc}") from exc + + try: + content, mime_type, ext, file_type = await process_upload(file) + except FileValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + # Build source identifier from recipe content + recipe_json_str = recipe.model_dump_json() + recipe_hash = hashlib.sha256(recipe_json_str.encode()).hexdigest()[:16] + source_url = f"upload://{recipe_hash}" + + # Upsert the recipe into the cache table + cached = await get_cached_recipe(source_url) + if not cached: + cached = await store_recipe( + url=source_url, + recipe=recipe, + content_hash=recipe_hash, + parsed_with=Parser(parsing_method), + ) + + # Build storage key using pre-generated user_recipe_id + user_recipe_id = str(uuid.uuid4()) + safe_filename = f"source{ext}" + storage_key = f"users/{user.id}/recipes/{user_recipe_id}/{safe_filename}" + + is_image = file_type == "image" + thumbnail_key = storage_key if is_image else None + + # Upload file to storage first + await storage.upload(storage_key, content, mime_type) + + try: + user_recipe, is_new = await save_user_recipe( + user_id=user.id, + recipe_id=cached.id, + recipe_data=recipe, + tags=tags, + notes=notes, + user_recipe_id=user_recipe_id, + source_file_key=storage_key, + thumbnail_key=thumbnail_key, + ) + except Exception: + # Roll back the stored file if DB save fails + try: + await storage.delete(storage_key) + except Exception: + logger.warning("Failed to clean up storage key %s after DB error", storage_key) + raise + + return SaveRecipeResponse( + user_recipe_id=user_recipe.id, + recipe_id=cached.id, + source_url=source_url, + parsing_method=parsing_method, + created_at=user_recipe.created_at.isoformat(), + is_new=is_new, + ) + + @router.get("/me/recipes") async def list_recipes( user: Annotated[User, Depends(get_user)], + storage: Annotated[StorageBackend, Depends(get_storage)], cursor: Annotated[str | None, Query(description="Cursor for pagination")] = None, limit: Annotated[int, Query(ge=1, le=100, description="Number of recipes")] = 50, tags: Annotated[str | None, Query(description="Filter by tags (comma-separated)")] = None, @@ -243,9 +355,10 @@ async def list_recipes( id=r.id, source_url=r.source_url, title=r.title, - image_url=r.image_url, + image_url=_resolve_image_url(r.image_url, r.thumbnail_key, storage), is_modified=r.is_modified, tags=r.tags, + source_file_url=storage.get_url(r.source_file_key) if r.source_file_key else None, created_at=r.created_at.isoformat(), updated_at=r.updated_at.isoformat(), ) @@ -260,6 +373,7 @@ async def list_recipes( async def get_recipe( recipe_id: str, user: Annotated[User, Depends(get_user)], + storage: Annotated[StorageBackend, Depends(get_storage)], ) -> GetUserRecipeResponse: """Get full details of a specific recipe from the user's collection.""" result = await get_user_recipe_with_lineage(user.id, recipe_id) @@ -269,6 +383,13 @@ async def get_recipe( user_recipe, source_recipe = result + # Build recipe with resolved image URL if we have a stored thumbnail + recipe = user_recipe.recipe + if user_recipe.thumbnail_key: + recipe = Recipe( + **{**recipe.model_dump(), "image": storage.get_url(user_recipe.thumbnail_key)} + ) + return GetUserRecipeResponse( id=user_recipe.id, source_url=source_recipe.source_url, @@ -276,11 +397,14 @@ async def get_recipe( is_modified=user_recipe.is_modified, notes=user_recipe.notes, tags=user_recipe.tags, - recipe=user_recipe.recipe, + recipe=recipe, lineage=RecipeLineage( recipe_id=source_recipe.id, parsed_at=source_recipe.created_at.isoformat(), ), + source_file_url=storage.get_url(user_recipe.source_file_key) + if user_recipe.source_file_key + else None, created_at=user_recipe.created_at.isoformat(), updated_at=user_recipe.updated_at.isoformat(), ) @@ -321,3 +445,67 @@ async def delete_recipe( if not deleted: raise HTTPException(status_code=404, detail="Recipe not found") + + +@router.post("/me/recipes/{recipe_id}/thumbnail", status_code=200) +async def upload_recipe_thumbnail( + recipe_id: str, + file: Annotated[UploadFile, File(description="Thumbnail image")], + user: Annotated[User, Depends(get_user)], + storage: Annotated[StorageBackend, Depends(get_storage)], +) -> ThumbnailUploadResponse: + """Upload or replace the thumbnail image for a saved recipe.""" + # Check recipe exists and belongs to user + existing = await get_user_recipe(user.id, recipe_id) + if existing is None: + raise HTTPException(status_code=404, detail="Recipe not found") + + try: + content, mime_type, ext, file_type = await process_upload(file) + except FileValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if file_type != "image": + raise HTTPException(status_code=400, detail="Only image files are accepted for thumbnails") + + new_key = f"users/{user.id}/recipes/{recipe_id}/thumbnail{ext}" + old_key = existing.thumbnail_key + + await storage.upload(new_key, content, mime_type) + + updated = await update_recipe_thumbnail_key(recipe_id, user.id, new_key) + if not updated: + try: + if old_key != new_key: + await storage.delete(new_key) + except Exception: + pass + raise HTTPException(status_code=404, detail="Recipe not found") + + if old_key and old_key != new_key: + try: + await storage.delete(old_key) + except Exception: + logger.warning("Failed to delete old thumbnail %s", old_key) + + return ThumbnailUploadResponse(image_url=storage.get_url(new_key)) + + +@router.delete("/me/recipes/{recipe_id}/thumbnail", status_code=204) +async def delete_recipe_thumbnail( + recipe_id: str, + user: Annotated[User, Depends(get_user)], + storage: Annotated[StorageBackend, Depends(get_storage)], +) -> None: + """Remove the thumbnail for a saved recipe.""" + existing = await get_user_recipe(user.id, recipe_id) + if existing is None: + raise HTTPException(status_code=404, detail="Recipe not found") + + if existing.thumbnail_key: + try: + await storage.delete(existing.thumbnail_key) + except Exception: + logger.warning("Failed to delete thumbnail %s", existing.thumbnail_key) + + await update_recipe_thumbnail_key(recipe_id, user.id, None) diff --git a/apps/kitchen_mate/src/kitchen_mate/schemas.py b/apps/kitchen_mate/src/kitchen_mate/schemas.py index 9d3db20..37b1136 100644 --- a/apps/kitchen_mate/src/kitchen_mate/schemas.py +++ b/apps/kitchen_mate/src/kitchen_mate/schemas.py @@ -186,6 +186,7 @@ class UserRecipeSummaryResponse(BaseModel): image_url: str | None = Field(description="Recipe image URL") is_modified: bool = Field(description="Whether user has modified the recipe") tags: list[str] | None = Field(description="User-defined tags") + source_file_url: str | None = Field(default=None, description="URL to the uploaded source file") created_at: str = Field(description="When saved to collection") updated_at: str = Field(description="Last modification time") @@ -216,6 +217,7 @@ class GetUserRecipeResponse(BaseModel): tags: list[str] | None = Field(description="User-defined tags") recipe: Recipe = Field(description="The recipe data") lineage: RecipeLineage = Field(description="Source recipe information") + source_file_url: str | None = Field(default=None, description="URL to the uploaded source file") created_at: str = Field(description="When saved to collection") updated_at: str = Field(description="Last modification time") @@ -245,3 +247,9 @@ class UpdateUserRecipeResponse(BaseModel): id: str = Field(description="User recipe ID") is_modified: bool = Field(description="Whether the recipe has been modified") updated_at: str = Field(description="Last modification time") + + +class ThumbnailUploadResponse(BaseModel): + """Response body for uploading a recipe thumbnail.""" + + image_url: str = Field(description="URL to the uploaded thumbnail") diff --git a/apps/kitchen_mate/src/kitchen_mate/storage/__init__.py b/apps/kitchen_mate/src/kitchen_mate/storage/__init__.py new file mode 100644 index 0000000..05aa1f4 --- /dev/null +++ b/apps/kitchen_mate/src/kitchen_mate/storage/__init__.py @@ -0,0 +1,11 @@ +"""Storage abstraction for file uploads.""" + +from kitchen_mate.storage.backends import LocalStorageBackend, S3StorageBackend, StorageBackend +from kitchen_mate.storage.factory import get_storage + +__all__ = [ + "StorageBackend", + "LocalStorageBackend", + "S3StorageBackend", + "get_storage", +] diff --git a/apps/kitchen_mate/src/kitchen_mate/storage/backends.py b/apps/kitchen_mate/src/kitchen_mate/storage/backends.py new file mode 100644 index 0000000..480fd11 --- /dev/null +++ b/apps/kitchen_mate/src/kitchen_mate/storage/backends.py @@ -0,0 +1,123 @@ +"""Storage backend implementations.""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Protocol + +logger = logging.getLogger(__name__) + + +class StorageBackend(Protocol): + """Protocol for storage backends.""" + + async def upload(self, key: str, content: bytes, content_type: str) -> None: + """Upload a file to storage.""" + ... + + def get_url(self, key: str) -> str: + """Get the URL for a stored file.""" + ... + + async def delete(self, key: str) -> None: + """Delete a stored file.""" + ... + + +class LocalStorageBackend: + """Stores files on the local filesystem.""" + + def __init__(self, base_path: Path, base_url: str) -> None: + self._base_path = base_path + self._base_url = base_url.rstrip("/") + + async def upload(self, key: str, content: bytes, content_type: str) -> None: + file_path = self._resolve_path(key) + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_bytes(content) + + def get_url(self, key: str) -> str: + return f"{self._base_url}/{key}" + + async def delete(self, key: str) -> None: + file_path = self._resolve_path(key) + if file_path.exists(): + file_path.unlink() + + def _resolve_path(self, key: str) -> Path: + """Resolve a key to an absolute path, preventing path traversal.""" + base_resolved = self._base_path.resolve() + resolved = (base_resolved / key).resolve() + try: + resolved.relative_to(base_resolved) + except ValueError: + raise ValueError(f"Invalid key: {key}") + return resolved + + @property + def base_path(self) -> Path: + return self._base_path + + +class S3StorageBackend: + """Stores files in an S3-compatible object store.""" + + def __init__( + self, + bucket: str, + access_key_id: str, + secret_access_key: str, + region: str = "us-east-1", + endpoint_url: str | None = None, + public_base_url: str | None = None, + ) -> None: + self._bucket = bucket + self._access_key_id = access_key_id + self._secret_access_key = secret_access_key + self._region = region + self._endpoint_url = endpoint_url + self._public_base_url = public_base_url.rstrip("/") if public_base_url else None + + def _get_client(self): # type: ignore[return] + import boto3 # type: ignore[import-untyped] + from botocore.config import Config # type: ignore[import-untyped] + + return boto3.client( + "s3", + aws_access_key_id=self._access_key_id, + aws_secret_access_key=self._secret_access_key, + region_name=self._region, + endpoint_url=self._endpoint_url, + config=Config(signature_version="s3v4"), + ) + + async def upload(self, key: str, content: bytes, content_type: str) -> None: + client = self._get_client() + await asyncio.to_thread( + client.put_object, + Bucket=self._bucket, + Key=key, + Body=content, + ContentType=content_type, + ) + + def get_url(self, key: str) -> str: + if self._public_base_url: + return f"{self._public_base_url}/{key}" + # Generate presigned URL for private buckets + client = self._get_client() + return client.generate_presigned_url( + "get_object", + Params={"Bucket": self._bucket, "Key": key}, + ExpiresIn=3600, + ) + + async def delete(self, key: str) -> None: + client = self._get_client() + await asyncio.to_thread( + client.delete_object, + Bucket=self._bucket, + Key=key, + ) diff --git a/apps/kitchen_mate/src/kitchen_mate/storage/factory.py b/apps/kitchen_mate/src/kitchen_mate/storage/factory.py new file mode 100644 index 0000000..cd07175 --- /dev/null +++ b/apps/kitchen_mate/src/kitchen_mate/storage/factory.py @@ -0,0 +1,40 @@ +"""Storage backend factory and FastAPI dependency.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Annotated + +from fastapi import Depends, Request + +from kitchen_mate.config import Settings, get_settings +from kitchen_mate.storage.backends import LocalStorageBackend, S3StorageBackend, StorageBackend + + +def get_storage( + request: Request, + settings: Annotated[Settings, Depends(get_settings)], +) -> StorageBackend: + """FastAPI dependency that returns the configured storage backend. + + For local backend, the base_url is derived from the incoming request + so it works behind proxies and in dev/prod without extra config. + """ + if settings.storage.backend == "s3": + return S3StorageBackend( + bucket=settings.storage.s3.bucket or "", + access_key_id=settings.storage.s3.access_key_id or "", + secret_access_key=settings.storage.s3.secret_access_key or "", + region=settings.storage.s3.region, + endpoint_url=settings.storage.s3.endpoint_url, + public_base_url=settings.storage.public_base_url, + ) + + # Local backend + base_path = Path(settings.storage.local_path) + if settings.storage.public_base_url: + base_url = settings.storage.public_base_url + else: + # Build URL from request: scheme + host + /api/files + base_url = f"{request.url.scheme}://{request.url.netloc}/api/files" + return LocalStorageBackend(base_path=base_path, base_url=base_url) diff --git a/apps/kitchen_mate/tests/test_auth.py b/apps/kitchen_mate/tests/test_auth.py index 46e5678..9f979a9 100644 --- a/apps/kitchen_mate/tests/test_auth.py +++ b/apps/kitchen_mate/tests/test_auth.py @@ -42,7 +42,7 @@ def create_test_jwt(user_id: str, email: str, secret: str, expired: bool = False def test_get_current_user_valid_token(client: TestClient, settings_with_supabase: Settings) -> None: """Test retrieving current user with valid token.""" token = create_test_jwt( - "user-123", "test@example.com", settings_with_supabase.supabase_jwt_secret + "user-123", "test@example.com", settings_with_supabase.supabase.jwt_secret ) response = client.get("/api/auth/me", cookies={"access_token": token}) @@ -76,7 +76,7 @@ def test_get_current_user_expired_token( ) -> None: """Test that expired token returns 401.""" token = create_test_jwt( - "user-123", "test@example.com", settings_with_supabase.supabase_jwt_secret, expired=True + "user-123", "test@example.com", settings_with_supabase.supabase.jwt_secret, expired=True ) response = client.get("/api/auth/me", cookies={"access_token": token}) @@ -117,7 +117,7 @@ def test_get_current_user_wrong_audience( "exp": datetime.now(timezone.utc) + timedelta(hours=1), } - token = jwt.encode(payload, settings_with_supabase.supabase_jwt_secret, algorithm="HS256") + token = jwt.encode(payload, settings_with_supabase.supabase.jwt_secret, algorithm="HS256") response = client.get("/api/auth/me", cookies={"access_token": token}) diff --git a/apps/kitchen_mate/tests/test_files.py b/apps/kitchen_mate/tests/test_files.py index e42198b..7603350 100644 --- a/apps/kitchen_mate/tests/test_files.py +++ b/apps/kitchen_mate/tests/test_files.py @@ -2,15 +2,23 @@ from __future__ import annotations +import asyncio +import tempfile +from pathlib import Path + import pytest +from fastapi import HTTPException +from kitchen_mate.auth import DEFAULT_USER from kitchen_mate.files import ( + MAX_DOCUMENT_SIZE, + MAX_IMAGE_SIZE, FileValidationError, detect_file_type, validate_file_size, - MAX_IMAGE_SIZE, - MAX_DOCUMENT_SIZE, ) +from kitchen_mate.routes.files import serve_file +from kitchen_mate.storage.backends import LocalStorageBackend class TestDetectFileType: @@ -210,3 +218,28 @@ def test_document_exceeds_limit(self) -> None: assert "exceeds" in str(exc_info.value) assert "20" in str(exc_info.value) # 20MB limit + + +def test_files_route_blocks_parent_directory_traversal() -> None: + """Test the local files route rejects traversal into sibling paths.""" + with tempfile.TemporaryDirectory() as tmp_dir: + base_path = Path(tmp_dir) / "uploads" + base_path.mkdir() + + sibling_dir = Path(tmp_dir) / "uploads_other" + sibling_dir.mkdir() + (sibling_dir / "secret.txt").write_text("secret", encoding="utf-8") + + storage = LocalStorageBackend(base_path=base_path, base_url="http://testserver/api/files") + + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + serve_file( + file_key="users/local/../../../uploads_other/secret.txt", + user=DEFAULT_USER, + storage=storage, + ) + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Invalid file key" diff --git a/apps/kitchen_mate/tests/test_user_recipes.py b/apps/kitchen_mate/tests/test_user_recipes.py index 4c43ba1..d433eb3 100644 --- a/apps/kitchen_mate/tests/test_user_recipes.py +++ b/apps/kitchen_mate/tests/test_user_recipes.py @@ -5,16 +5,28 @@ import asyncio import tempfile from datetime import datetime, timedelta, timezone +from io import BytesIO from typing import TYPE_CHECKING, Generator import pytest +from fastapi import HTTPException, UploadFile from fastapi.testclient import TestClient from jose import jwt from kitchen_mate.config import Settings, get_settings -from kitchen_mate.database import create_tables, init_database, store_recipe +from kitchen_mate.database import ( + close_database, + create_tables, + get_user_recipe, + init_database, + save_user_recipe, + store_recipe, +) from kitchen_mate.main import app from kitchen_mate.schemas import Parser +from kitchen_mate.auth import DEFAULT_USER +from kitchen_mate.storage.backends import StorageBackend +import kitchen_mate.routes.me as me_routes from recipe_clipper.models import Ingredient, Recipe if TYPE_CHECKING: @@ -340,6 +352,131 @@ def test_save_recipe_restores_deleted(client_with_db: TestClient, sample_recipe: assert len(response.json()["recipes"]) == 1 +def test_upload_save_updates_existing_file_metadata(sample_recipe: Recipe) -> None: + """Test duplicate uploaded saves replace file metadata on the existing recipe row.""" + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + + try: + asyncio.run(init_database(db_path)) + asyncio.run(create_tables()) + + cached = asyncio.run( + store_recipe( + "upload://abc123hash", + sample_recipe, + "abc123hash", + Parser.llm_image, + ) + ) + + first_saved, first_is_new = asyncio.run( + save_user_recipe( + user_id="local", + recipe_id=cached.id, + recipe_data=sample_recipe, + source_file_key="users/local/recipes/original/source.png", + thumbnail_key="users/local/recipes/original/source.png", + ) + ) + assert first_is_new is True + + second_saved, second_is_new = asyncio.run( + save_user_recipe( + user_id="local", + recipe_id=cached.id, + recipe_data=sample_recipe, + source_file_key="users/local/recipes/replacement/source.png", + thumbnail_key="users/local/recipes/replacement/source.png", + ) + ) + assert second_is_new is False + assert second_saved.id == first_saved.id + assert second_saved.source_file_key == "users/local/recipes/replacement/source.png" + assert second_saved.thumbnail_key == "users/local/recipes/replacement/source.png" + + stored = asyncio.run(get_user_recipe("local", first_saved.id)) + assert stored is not None + assert stored.source_file_key == "users/local/recipes/replacement/source.png" + assert stored.thumbnail_key == "users/local/recipes/replacement/source.png" + finally: + asyncio.run(close_database()) + + +def test_thumbnail_upload_keeps_old_thumbnail_when_db_update_fails(sample_recipe: Recipe) -> None: + """Test thumbnail replacement does not delete the old object before DB success.""" + + class FakeStorage(StorageBackend): + def __init__(self) -> None: + self.uploaded: list[str] = [] + self.deleted: list[str] = [] + + async def upload(self, key: str, content: bytes, content_type: str) -> None: + self.uploaded.append(key) + + def get_url(self, key: str) -> str: + return f"https://storage.test/{key}" + + async def delete(self, key: str) -> None: + self.deleted.append(key) + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + db_path = f.name + + try: + asyncio.run(init_database(db_path)) + asyncio.run(create_tables()) + + cached = asyncio.run( + store_recipe( + "https://example.com/cake", + sample_recipe, + "abc123hash", + Parser.recipe_scrapers, + ) + ) + old_key = "users/local/recipes/existing-thumbnail/thumbnail.jpg" + saved, _ = asyncio.run( + save_user_recipe( + user_id="local", + recipe_id=cached.id, + recipe_data=sample_recipe, + thumbnail_key=old_key, + ) + ) + + original_update = me_routes.update_recipe_thumbnail_key + + async def fail_update(user_recipe_id: str, user_id: str, thumbnail_key: str | None) -> bool: + return False + + me_routes.update_recipe_thumbnail_key = fail_update + try: + upload = UploadFile( + filename="thumb.png", + file=BytesIO(b"\x89PNG\r\n\x1a\n" + b"\x00" * 128), + ) + fake_storage = FakeStorage() + with pytest.raises(HTTPException) as exc_info: + asyncio.run( + me_routes.upload_recipe_thumbnail( + recipe_id=saved.id, + file=upload, + user=DEFAULT_USER, + storage=fake_storage, + ) + ) + finally: + me_routes.update_recipe_thumbnail_key = original_update + + assert exc_info.value.status_code == 404 + assert fake_storage.uploaded == [f"users/local/recipes/{saved.id}/thumbnail.png"] + assert fake_storage.deleted == [f"users/local/recipes/{saved.id}/thumbnail.png"] + assert old_key not in fake_storage.deleted + finally: + asyncio.run(close_database()) + + # ============================================================================= # Multi-Tenant Mode Tests # ============================================================================= diff --git a/render.yaml b/render.yaml index 08eb886..5556a70 100644 --- a/render.yaml +++ b/render.yaml @@ -19,8 +19,8 @@ services: value: /data/kitchenmate.db - key: ANTHROPIC_API_KEY sync: false # Set manually in Render dashboard (secret) - - key: LLM_ALLOWED_IPS - sync: false # Set manually in Render dashboard + - key: PRO_USER_IDS + sync: false # Comma-separated Supabase user IDs with Pro tier access - key: CORS_ORIGINS sync: false # Set manually in Render dashboard # Multi-tenant mode (Supabase authentication) @@ -29,3 +29,18 @@ services: sync: false # Set manually in Render dashboard (enables multi-tenant) - key: SUPABASE_ANON_KEY sync: false # Set manually in Render dashboard (frontend auth client) + # File storage + - key: STORAGE_BACKEND + value: s3 + - key: STORAGE_PUBLIC_BASE_URL + sync: false # Optional: public R2 bucket URL (omit to use presigned URLs) + - key: S3_BUCKET + sync: false # Set manually in Render dashboard (secret) + - key: S3_ACCESS_KEY_ID + sync: false # Set manually in Render dashboard (secret) + - key: S3_SECRET_ACCESS_KEY + sync: false # Set manually in Render dashboard (secret) + - key: S3_REGION + value: us-east-1 + - key: S3_ENDPOINT_URL + sync: false # Required for R2/MinIO: e.g. https://.r2.cloudflarestorage.com diff --git a/uv.lock b/uv.lock index c2277c6..5bb47ec 100644 --- a/uv.lock +++ b/uv.lock @@ -117,6 +117,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "boto3" +version = "1.42.91" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/c0/98b8cec7ca22dde776df48c58940ae1abc425593959b7226e270760d726f/boto3-1.42.91.tar.gz", hash = "sha256:03d70532b17f7f84df37ca7e8c21553280454dea53ae12b15d1cfef9b16fcb8a", size = 113181, upload-time = "2026-04-17T19:31:06.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/29/faba6521257c34085cc9b439ef98235b581772580f417fa3629728007270/boto3-1.42.91-py3-none-any.whl", hash = "sha256:04e72071cde022951ce7f81bd9933c90095ab8923e8ced61c8dacfe9edac0f5c", size = 140553, upload-time = "2026-04-17T19:31:02.57Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.91" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/bc/a4b7c46471c2e789ad8c4c7acfd7f302fdb481d93ff870f441249b924ae6/botocore-1.42.91.tar.gz", hash = "sha256:d252e27bc454afdbf5ed3dc617aa423f2c855c081e98b7963093399483ecc698", size = 15213010, upload-time = "2026-04-17T19:30:50.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/fc/24cc0a47c824f13933e210e9ad034b4fba22f7185b8d904c0fbf5a3b2be8/botocore-1.42.91-py3-none-any.whl", hash = "sha256:7a28c3cc6bfab5724ad18899d52402b776a0de7d87fa20c3c5270bcaaf199ce8", size = 14897344, upload-time = "2026-04-17T19:30:44.245Z" }, +] + [[package]] name = "brotli" version = "1.2.0" @@ -184,6 +212,11 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/84/85/57c314a6b35336efbbdc13e5fc9ae13f6b60a0647cfa7c1221178ac6d8ae/brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb", size = 476682, upload-time = "2025-11-21T18:17:57.334Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/87/ba6298c3d7f8d66ce80d7a487f2a487ebae74a79c6049c7c2990178ce529/brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68", size = 433038, upload-time = "2026-03-05T17:57:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/00/49/16c7a77d1cae0519953ef0389a11a9c2e2e62e87d04f8e7afbae40124255/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af", size = 1541124, upload-time = "2026-03-05T17:57:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/e8/17/fab2c36ea820e2288f8c1bf562de1b6cd9f30e28d66f1ce2929a4baff6de/brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9", size = 1541983, upload-time = "2026-03-05T17:57:41.061Z" }, + { url = "https://files.pythonhosted.org/packages/78/c9/849a669b3b3bb8ac96005cdef04df4db658c33443a7fc704a6d4a2f07a56/brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2", size = 349046, upload-time = "2026-03-05T17:57:42.76Z" }, + { url = "https://files.pythonhosted.org/packages/a4/25/09c0fd21cfc451fa38ad538f4d18d8be566746531f7f27143f63f8c45a9f/brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0", size = 385653, upload-time = "2026-03-05T17:57:44.224Z" }, { url = "https://files.pythonhosted.org/packages/e4/df/a72b284d8c7bef0ed5756b41c2eb7d0219a1dd6ac6762f1c7bdbc31ef3af/brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4", size = 432340, upload-time = "2025-11-21T18:17:42.277Z" }, { url = "https://files.pythonhosted.org/packages/74/2b/cc55a2d1d6fb4f5d458fba44a3d3f91fb4320aa14145799fd3a996af0686/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7", size = 1534002, upload-time = "2025-11-21T18:17:43.746Z" }, { url = "https://files.pythonhosted.org/packages/e4/9c/d51486bf366fc7d6735f0e46b5b96ca58dc005b250263525a1eea3cd5d21/brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990", size = 1536547, upload-time = "2025-11-21T18:17:45.729Z" }, @@ -1386,6 +1419,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "jstyleson" version = "0.0.2" @@ -1399,6 +1441,7 @@ source = { editable = "apps/kitchen_mate" } dependencies = [ { name = "aiosqlite" }, { name = "alembic" }, + { name = "boto3" }, { name = "fastapi" }, { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, @@ -1421,6 +1464,7 @@ dev = [ requires-dist = [ { name = "aiosqlite", specifier = ">=0.19.0" }, { name = "alembic", specifier = ">=1.13.0" }, + { name = "boto3", specifier = ">=1.42.91" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, @@ -2289,6 +2333,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -2563,6 +2619,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl", hash = "sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size = 13744730, upload-time = "2025-12-11T21:39:29.659Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + [[package]] name = "shellingham" version = "1.5.4"