diff --git a/backend/integrations/shopify_routes.py b/backend/integrations/shopify_routes.py index ee72a85eb..3d3145c3e 100644 --- a/backend/integrations/shopify_routes.py +++ b/backend/integrations/shopify_routes.py @@ -1,7 +1,16 @@ from datetime import datetime +import hashlib +import hmac import logging +import os +import secrets +import time +from typing import Optional +from urllib.parse import urlencode + from ecommerce.models import EcommerceStore from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import RedirectResponse from pydantic import BaseModel from sqlalchemy.orm import Session @@ -16,14 +25,145 @@ # Auth Type: OAuth2 router = APIRouter(prefix="/api/shopify", tags=["shopify"]) +# OAuth state validity window (seconds). Short so a leaked state token can't be +# replayed for long; long enough for the Shopify authorize round-trip. +_STATE_TTL_SECONDS = 600 + + +def _env_or_die(name: str) -> str: + """Return env var value or raise 500 (Shopify OAuth misconfiguration).""" + val = os.getenv(name, "") + if not val: + raise HTTPException( + status_code=500, + detail=f"Shopify OAuth not configured. Please set {name} in the backend environment." + ) + return val + + +def _shopify_state_secret() -> str: + """Secret used to sign the OAuth ``state`` so the callback can verify that + the merchant came back from the same authorize request. Falls back to the + app SECRET_KEY when SHOPIFY_STATE_SECRET is unset (test/dev).""" + secret = os.getenv("SHOPIFY_STATE_SECRET") or os.getenv("SECRET_KEY") or "" + if not secret: + raise HTTPException(status_code=500, detail="Shopify OAuth not configured. SECRET_KEY missing.") + return secret + + +def _sign_state(user_id: str, workspace_id: str, shop: str) -> str: + """Return a signed, single-use OAuth state token. + + Binds the authorize request to the user, workspace, AND the target shop, + with a random nonce and an expiry. The nonce makes each state one-time-only + (a previously-issued state cannot be replayed with a fresh code against + another shop), and the expiry bounds reuse.""" + nonce = secrets.token_hex(16) + exp = str(int(time.time()) + _STATE_TTL_SECONDS) + # '|'-separated payload; shop may contain dots so we never split state on '.'. + payload = f"{user_id}|{workspace_id}|{shop}|{nonce}|{exp}" + sig = hmac.new(_shopify_state_secret().encode(), payload.encode(), hashlib.sha256).hexdigest() + return f"{sig}.{payload}" + + +def _verify_state(state: str, expected_shop: Optional[str] = None) -> tuple: + """Verify a signed OAuth state token. + + Returns ``(user_id, workspace_id)`` or raises 400. Rejects expired states, + tampered signatures, and states bound to a different shop than the one the + callback received (cross-shop replay).""" + if not state: + raise HTTPException(status_code=400, detail="Missing OAuth state") + if "." not in state: + raise HTTPException(status_code=400, detail="Invalid OAuth state") + sig, payload = state.split(".", 1) + parts = payload.split("|") + if len(parts) != 5: + raise HTTPException(status_code=400, detail="Invalid OAuth state") + user_id, workspace_id, shop, nonce, exp = parts + expected = hmac.new(_shopify_state_secret().encode(), payload.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(sig, expected): + raise HTTPException(status_code=400, detail="Invalid OAuth state") + try: + if int(exp) < int(time.time()): + raise HTTPException(status_code=400, detail="OAuth state expired") + except ValueError: + raise HTTPException(status_code=400, detail="Invalid OAuth state") + if expected_shop is not None: + norm_expected = expected_shop if expected_shop.endswith(".myshopify.com") else f"{expected_shop}.myshopify.com" + if shop != norm_expected: + raise HTTPException(status_code=400, detail="OAuth state shop mismatch") + return user_id, workspace_id + + +def _redirect_base_url() -> str: + """Public base URL for Shopify's browser redirect (NOT the user's machine). + + Resolved from deployment config: ATOM_PUBLIC_URL -> ATOM_BASE_URL -> + PYTHON_BACKEND_URL -> NEXT_PUBLIC_API_URL. Fails closed (no loopback + fallback) unless SHOPIFY_DEV_LOOPBACK=1 is explicitly set for local dev.""" + if os.getenv("SHOPIFY_DEV_LOOPBACK", "").lower() in ("1", "true", "yes", "on"): + return os.getenv("SHOPIFY_DEV_BASE_URL", "http://localhost:8000").rstrip("/") + for name in ("ATOM_PUBLIC_URL", "ATOM_BASE_URL", "PYTHON_BACKEND_URL", "NEXT_PUBLIC_API_URL"): + val = os.getenv(name, "").strip() + if val: + return val.rstrip("/") + raise HTTPException( + status_code=500, + detail="Shopify OAuth not configured: no public ATOM URL set. " + "Set ATOM_PUBLIC_URL (or SHOPIFY_DEV_LOOPBACK=1 for local dev)." + ) + + +def _callback_url() -> str: + return f"{_redirect_base_url()}/api/shopify/auth/callback" + @router.get("/auth/url") -async def get_auth_url(): - """Get Shopify OAuth URL""" +async def get_auth_url( + shop: str = Query(..., description="Shop name (e.g. my-great-store)"), + current_user: User = Depends(get_current_user), +): + """Get Shopify OAuth URL for a shop (authenticated). + + Builds a signed OAuth ``state`` bound to the authenticated user + their + workspace so the callback can verify the merchant is the same principal. + The redirect URI is derived from deployment config, never hardcoded to a + local machine. Requires SHOPIFY_API_KEY / SHOPIFY_API_SECRET env vars. + """ + client_id = _env_or_die("SHOPIFY_API_KEY") + _env_or_die("SHOPIFY_API_SECRET") + shop_url = shop if shop.endswith(".myshopify.com") else f"{shop}.myshopify.com" + scopes = "read_products,write_products,read_content,write_content,read_orders,read_customers,write_orders,write_draft_orders" + workspace_id = getattr(current_user, "workspace_id", None) or "default" + params = { + "client_id": client_id, + "scope": scopes, + "redirect_uri": _callback_url(), + "state": _sign_state(str(current_user.id), workspace_id, shop_url), + } + url = f"https://{shop_url}/admin/oauth/authorize?{urlencode(params)}" return { - "url": "https://{shop}.myshopify.com/admin/oauth/authorize?client_id=INSERT_CLIENT_ID&scope=read_products,read_orders,write_webhooks&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fapi%2Fshopify%2Fcallback", + "url": url, + "shop": shop_url, + "configured": True, "timestamp": datetime.now().isoformat() } +@router.get("/connection") +async def shopify_connection( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Whether a Shopify store is connected for the caller's workspace.""" + from ecommerce.models import EcommerceStore as EcommerceStoreModel + ws_id = getattr(current_user, "workspace_id", None) or "default" + store = db.query(EcommerceStoreModel).filter( + EcommerceStoreModel.tenant_id == ws_id + ).first() + if not store or not store.access_token: + return {"ok": True, "connected": False, "shop": None} + return {"ok": True, "connected": True, "shop": store.shop_domain} + # Initialize service shopify_service = ShopifyService() @@ -54,41 +194,137 @@ class ArticleCreateRequest(BaseModel): tags: str = "" published: bool = True +@router.get("/auth/callback") +async def shopify_auth_callback_get( + code: str = Query(...), + state: str = Query(...), + shop: str = Query(""), + hmac: str = Query(""), + db: Session = Depends(get_db), +): + """OAuth callback — browser GET redirect from Shopify. + + Validates the signed ``state`` (bound to the authenticated user + workspace + from the authorize step), then exchanges ``code`` for an access token and + persists the store under that verified workspace. Redirects the merchant's + browser back to the frontend connect page on success/failure. + """ + # 2. Exchange the code for an access token. + if not shop: + # Shopify sends the shop domain in the state-verified authorize flow via + # the shop query param; require it. + raise HTTPException(status_code=400, detail="Missing shop parameter") + + # 3. Verify OAuth state -> recover the owning user + workspace, AND enforce + # that the state was issued for THIS shop (blocks cross-shop replay: + # reusing an old state token with a fresh code for a different store). + # No client-supplied workspace is trusted — it comes from the signed state. + try: + user_id, workspace_id = _verify_state(state, expected_shop=shop) + except HTTPException: + logger.warning(f"Shopify callback: state verification failed for shop {shop}") + return _redirect_connect(connected=False, shop=shop) + + # 4. Exchange the code for an access token. + try: + token_data = await shopify_service.exchange_token(code, shop) + except Exception: + logger.error("Shopify token exchange failed in callback") + return _redirect_connect(connected=False, shop=shop) + + access_token = token_data.get("access_token") + if not access_token: + logger.error("Shopify callback: no access_token returned") + return _redirect_connect(connected=False, shop=shop) + + # 5. Persist the store, scoped to the verified workspace. Refuse to + # reassign a store already owned by a DIFFERENT workspace. + store = db.query(EcommerceStore).filter( + EcommerceStore.shop_domain == shop + ).first() + if store is not None: + existing_owner = store.tenant_id or "default" + if existing_owner != workspace_id: + logger.warning( + f"Shopify callback: refusing to reassign store {shop} " + f"(owner {existing_owner} != verified {workspace_id})" + ) + return _redirect_connect(connected=False, shop=shop) + store.access_token = access_token + store_meta = dict(store.metadata_json or {}) + store_meta["workspace_id"] = workspace_id + store.metadata_json = store_meta + else: + store = EcommerceStore( + shop_domain=shop, + access_token=access_token, + platform="shopify", + tenant_id=workspace_id, + metadata_json={"workspace_id": workspace_id}, + ) + db.add(store) + db.commit() + + logger.info(f"Shopify store {shop} connected for workspace {workspace_id}") + return _redirect_connect(connected=True, shop=shop) + + +def _redirect_connect(connected: bool, shop: str) -> RedirectResponse: + """Redirect the merchant's browser to the frontend Shopify connect page with + the result encoded in the query string (frontend clears the stored state).""" + frontend = os.getenv("FRONTEND_URL") or _redirect_base_url().replace("/api", "") or "http://localhost:3000" + params = {"connected": "true" if connected else "false", "shop": shop} + return RedirectResponse(f"{frontend}/integrations/shopify?{urlencode(params)}", status_code=303) + + @router.post("/auth/callback") -async def shopify_auth_callback(auth_request: ShopifyAuthRequest, db: Session = Depends(get_db)): - """Exchange authorization code for access token and save store""" +async def shopify_auth_callback_post( + auth_request: ShopifyAuthRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """OAuth callback — authenticated JSON alternative for non-browser clients. + + Accepts the client's token as the identity source (state is implicit in the + authenticated principal). Ownership is still enforced: an existing store + owned by a different workspace is never reassigned. + """ try: + workspace_id = getattr(current_user, "workspace_id", None) or "default" token_data = await shopify_service.exchange_token(auth_request.code, auth_request.shop) access_token = token_data["access_token"] - - # Save or update EcommerceStore + store = db.query(EcommerceStore).filter( EcommerceStore.shop_domain == auth_request.shop ).first() - - if not store: + if store is not None: + existing_owner = store.tenant_id or "default" + if existing_owner != workspace_id: + raise HTTPException(status_code=403, detail="Store belongs to another workspace") + store.access_token = access_token + store_meta = dict(store.metadata_json or {}) + store_meta["workspace_id"] = workspace_id + store.metadata_json = store_meta + else: store = EcommerceStore( shop_domain=auth_request.shop, access_token=access_token, platform="shopify", - metadata_json={"workspace_id": auth_request.workspace_id} + tenant_id=workspace_id, + metadata_json={"workspace_id": workspace_id}, ) db.add(store) - else: - store.access_token = access_token - store_meta = dict(store.metadata_json or {}) - store_meta["workspace_id"] = auth_request.workspace_id - store.metadata_json = store_meta - db.commit() - + return { "ok": True, "access_token": access_token, "scope": token_data.get("scope"), "service": "shopify", - "workspace_id": auth_request.workspace_id + "workspace_id": workspace_id, } + except HTTPException: + raise except Exception as e: logger.error(f"Shopify callback error: {e}") raise HTTPException(status_code=400, detail="Internal error") diff --git a/backend/main_api_app.py b/backend/main_api_app.py index d4176beb0..911e9fc18 100644 --- a/backend/main_api_app.py +++ b/backend/main_api_app.py @@ -2499,6 +2499,17 @@ async def auto_load_integration_middleware(request, call_next): app.include_router(shopify_wh_router) + # Shopify REST API (products/blogs/articles/status + OAuth connect). + # Mounted here (unconditional) so the connect flow and agent tools work + # even when the webhook batch block below is skipped by a bad import. + try: + from integrations.shopify_routes import router as shopify_router + + app.include_router(shopify_router, tags=["Shopify"]) + logger.info("✓ Shopify REST routes loaded") + except (ImportError, TypeError) as e: + logger.warning(f"Shopify REST routes not found, skipping: {e}") + from integrations.atom_communication_memory_webhooks import ( atom_memory_webhooks_router, ) diff --git a/frontend-nextjs/next.config.js b/frontend-nextjs/next.config.js index f223ef2d5..7fae25554 100644 --- a/frontend-nextjs/next.config.js +++ b/frontend-nextjs/next.config.js @@ -100,6 +100,10 @@ const nextConfig = { source: "/api/integrations/:path*", destination: "http://127.0.0.1:8000/api/integrations/:path*", }, + { + source: "/api/shopify/:path*", + destination: "http://127.0.0.1:8000/api/shopify/:path*", + }, { source: "/api/workflows/:path*", destination: "http://127.0.0.1:8000/api/v1/workflow-ui/:path*", diff --git a/frontend-nextjs/pages/integrations/index.tsx b/frontend-nextjs/pages/integrations/index.tsx index 749218493..1bb718d09 100644 --- a/frontend-nextjs/pages/integrations/index.tsx +++ b/frontend-nextjs/pages/integrations/index.tsx @@ -46,6 +46,7 @@ import { Download, Upload, Edit, + ShoppingCart, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -187,6 +188,17 @@ const IntegrationsPage: React.FC = () => { documentation: "https://docs.microsoft.com/en-us/graph/api/resources/outlook", }, + { + id: "shopify", + name: "Shopify", + description: "Ecommerce store — agents create product listings and blog posts", + category: "ecommerce", + status: "complete", + connected: false, + icon: ShoppingCart, + color: "text-green-600", + documentation: "https://shopify.dev/docs/api/admin-graphql", + }, // Productivity & Project Management { diff --git a/frontend-nextjs/pages/integrations/shopify.tsx b/frontend-nextjs/pages/integrations/shopify.tsx new file mode 100644 index 000000000..afa4ccc5c --- /dev/null +++ b/frontend-nextjs/pages/integrations/shopify.tsx @@ -0,0 +1,139 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { NextPage } from "next"; +import Head from "next/head"; +import { useRouter } from "next/router"; + +const ShopifyPage: NextPage = () => { + const router = useRouter(); + const { connected, shop } = router.query as { connected?: string; shop?: string }; + const [isConnected, setIsConnected] = useState(connected === "true"); + const [connectedShop, setConnectedShop] = useState(shop || ""); + const [shopName, setShopName] = useState(""); + const [loading, setLoading] = useState(true); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); + + const getToken = () => + typeof window !== "undefined" + ? localStorage.getItem("auth_token") || localStorage.getItem("token") + : null; + + const checkConnection = useCallback(async () => { + try { + const token = getToken(); + const res = await fetch("/api/shopify/connection", { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (res.ok) { + const data = await res.json(); + setIsConnected(data.connected === true); + setConnectedShop(data.shop || ""); + } + } catch (e) { + console.error("Shopify connection check failed:", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + checkConnection(); + }, [checkConnection]); + + const handleConnect = async () => { + const name = shopName.trim(); + if (!name) { + setError("Please enter your Shopify shop name (e.g. my-great-store)."); + return; + } + setError(null); + setConnecting(true); + try { + const token = getToken(); + const res = await fetch( + `/api/shopify/auth/url?shop=${encodeURIComponent(name)}`, + { headers: token ? { Authorization: `Bearer ${token}` } : {} }, + ); + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.detail || "Failed to get authorization URL"); + } + const data = await res.json(); + if (data.url) { + window.location.href = data.url; + } else { + throw new Error("No authorization URL returned"); + } + } catch (e: any) { + setError(e?.message || "Failed to initiate Shopify connection."); + setConnecting(false); + } + }; + + return ( + <> + + Shopify | ATOM + +
+

Shopify

+

+ Connect your Shopify store so agents can create product + listings and publish blog posts automatically. +

+ + {loading ? ( +
Checking connection...
+ ) : isConnected ? ( +
+
+ Connected to {connectedShop || "your Shopify store"} +
+

+ Agents can now create listings and blog posts. Ask your + assistant things like: "Shopify pe naya product listing + banao" ya "Blog post likho aur publish karo". +

+ +
+ ) : ( +
+

Connect your store

+ {error && ( +
{error}
+ )} + + setShopName(e.target.value)} + /> +
+ +

+ Requires SHOPIFY_API_KEY / SHOPIFY_API_SECRET in the backend + environment and write_products / write_content scopes. +

+
+ )} +
+ + ); +}; + +export default ShopifyPage; \ No newline at end of file