diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 642994a..4d612cd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ Thank you for your interest in contributing! We follow a **Spec-Driven Developme ## 💻 Coding Standards - **No Inline Styles**: To ensure maintainability and style consistency, inline styles (`style={{ ... }}`) are prohibited in production components. Use Tailwind classes exclusively. +- **Markdown Security**: All AI-generated content MUST be passed through the sanitization pipeline in `TripViewer.tsx`. Never use `dangerouslySetInnerHTML` or disable sanitization without a security review. Any new component added to the markdown renderer must be explicitly whitelisted in the sanitization schema. ## 🔄 Workflow & Automation @@ -57,6 +58,16 @@ If working without an agent, follow these steps to keep the project state synchr | `npm run test:e2e` | Run E2E tests with Playwright | | `npm run type-check` | Validate TypeScript types | +## 📦 Deployment + +This project is optimized for static hosting on **GitHub Pages**: + +```bash +npm run deploy +``` + +_Note: This command runs `next build` with `output: 'export'` and pushes the `out` directory to the `gh-pages` branch._ + ## 🧪 Testing Strategy Beyond end-to-end testing, we use a multi-tiered strategy for component, accessibility, and visual validation: diff --git a/README.md b/README.md index b4b0bac..ff36d35 100644 --- a/README.md +++ b/README.md @@ -6,24 +6,44 @@ [![Framework](https://img.shields.io/badge/Built_with-React-61DAFB?logo=react)](https://react.dev) [![AI](https://img.shields.io/badge/AI_Model-Llama_3-7B1FA2?logo=meta)](https://huggingface.co) -**Quick-tripper** is a privacy-first, zero-backend travel companion web app that generates detailed road trip itineraries using AI. It operates entirely in your browser—no databases, no logins, no tracking. +**Quick-tripper** is a privacy-first, zero-backend travel companion web app that generates detailed road trip itineraries using AI. It operates entirely in your browser—no databases, no logins, no tracking. Now powered by **Llama 3.1** via the Hugging Face Serverless API. ## ✨ Features -- **🌍 Smart Itineraries**: Generate day-by-day travel plans using Llama 3 via the Hugging Face Router. -- **🗺️ Interactive Maps**: Embedded Google Maps for every journey, secured via client-side validation. +- **🌍 Smart Itineraries**: Generate day-by-day travel plans using Llama 3.1. +- **🗺️ Interactive Maps**: Embedded Google Maps for every journey, rendered securely from AI-generated routes. - **🔒 Privacy-First**: "Bring Your Own Key" (BYOK) model. Your API token is stored locally in your browser and never sent to our servers. -- **🔗 Compressed Sharing**: Share your entire itinerary via a single, ultra-compressed URL (powered by LZString). No backend storage required. +- **🔗 Compressed Sharing**: Share your entire itinerary via a single, ultra-compressed URL (powered by LZString). - **💾 Local Persistence**: Automatic synchronization with your browser's local storage. -- **🚦 Quality Control**: Pre-commit hooks via Husky and lint-staged to ensure type safety and code style. -- **🧪 Automated Testing**: Unit and utility testing powered by Vitest and JSDOM. -- **🚀 Serverless Architecture**: Designed to be hosted on static platforms like GitHub Pages. +- **🛡️ Secure Rendering**: Strict markdown sanitization to prevent XSS and ensure your browser environment remains safe. + +## 📖 How-to Use + +1. **Enter your Hugging Face API Token**: Get a free "User Access Token" from your [Hugging Face Settings](https://huggingface.co/settings/tokens). +2. **Describe your trip**: Use the prompt area to describe where you want to go, for how long, and what you like. +3. **Generate**: Click "Generate Itinerary" and wait a few seconds. +4. **Explore**: Use the interactive map and read the day-by-day suggestions. +5. **Manage**: Your trips are saved automatically. You can export them as JSON or share them via a unique link. + +## 🔒 Privacy & BYOK (Bring Your Own Key) + +Quick-tripper is designed to be **serverless and private**. We don't have a backend to store your data or your keys. + +- **Local Storage**: Your API token and trip history are stored ONLY in your browser's local storage. +- **Direct AI Calls**: The app communicates directly with Hugging Face APIs from your browser. +- **No Tracking**: We don't use cookies, analytics, or tracking scripts. + +To use the app, you need a **Hugging Face User Access Token**. + +- Visit [Hugging Face Settings](https://huggingface.co/settings/tokens). +- Create a new token (Read access is enough). +- Paste it into the top bar of Quick-tripper. ## 🛠️ Tech Stack - **Framework**: [Next.js 15+](https://nextjs.org) (App Router) - **Styling**: [TailwindCSS 4](https://tailwindcss.com) + [DaisyUI 5](https://daisyui.com) -- **AI Integration**: [Hugging Face Router](https://huggingface.co/docs/hub/spaces-router) (OpenAI-compatible) +- **AI Integration**: [Llama 3.1 8B](https://huggingface.co/meta-llama/Llama-3.1-8B-Instruct) via Hugging Face Router. - **Data Compression**: [LZString](https://pieroxy.net/lua/lz-string/index.html) - **Testing**: [Vitest](https://vitest.dev) + [React Testing Library](https://testing-library.com) - **Automation**: [Husky](https://typicode.github.io/husky/) + [lint-staged](https://github.com/lint-staged/lint-staged) @@ -37,21 +57,20 @@ This project follows **Spec-Driven Development (SDD)** to maintain a clear roadm ## 🚀 Getting Started -1. **Get an API Key**: Obtain a free User Access Token from [Hugging Face Settings](https://huggingface.co/settings/tokens). -2. **Clone the Repo**: +1. **Clone the Repo**: ```bash git clone https://github.com/gehdoc/quick-tripper.git cd quick-tripper ``` -3. **Install Dependencies**: +2. **Install Dependencies**: ```bash npm install ``` -4. **Run Locally**: +3. **Run Locally**: ```bash npm run dev ``` -5. **Open the App**: Visit `http://localhost:3000` (development) or `http://localhost:3000/quick-tripper` (production-emulated) and enter your API key in the top bar. +4. **Open the App**: Visit `http://localhost:3000` (development) or `http://localhost:3000/quick-tripper` (production-emulated) and enter your API key in the top bar. ## 🏗️ Architecture @@ -68,16 +87,6 @@ To maintain a robust "zero-backend" application, we rely on a multi-tiered valid 5. **E2E Testing**: Playwright validates full user workflows and "golden paths" in a real browser environment. 6. **Spec-First Implementation**: Every change is traced back to a technical specification in `specs/`, ensuring architectural alignment. -## 📦 Deployment - -This project is optimized for static hosting on **GitHub Pages**: - -```bash -npm run deploy -``` - -_Note: This command runs `next build` with `output: 'export'` and pushes the `out` directory to the `gh-pages` branch._ - ## 📄 License MIT © [Quick-tripper](https://github.com/gehdoc/quick-tripper) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 31852fd..ccfba33 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,29 @@ When the `Trip` data structure changes in a breaking way, follow these steps to 5. **Test**: - Add test cases in `src/tests/migration.test.ts` to verify the upgrade path from `v` to `v`. +## AI Integration + +The application uses **Llama 3.1 (8B)** for itinerary generation, accessed via the Hugging Face Serverless API. + +### Structured Output + +We enforce a JSON schema through strict system prompts. This allows us to reliably extract: + +- `title`: A clean summary of the trip. +- `start`/`stop`: Geocodable endpoints for map rendering and future filtering. +- `content`: The markdown-formatted itinerary. + +## Security Architecture + +### Markdown Sanitization (XSS Prevention) + +To maintain user safety in a BYOK (Bring Your Own Key) environment, the application implements a strict **Whitelist-Only** sanitization policy for AI-generated content. + +1. **Strict Whitelisting**: We use `rehype-sanitize` to strip all potentially dangerous HTML tags (`\n\n', }; const { container } = render( {}} />); - expect(screen.getByText(/Blocked unsafe iframe/i)).toBeInTheDocument(); + + // These should all be stripped by the sanitizer or components + expect(container.querySelector('script')).not.toBeInTheDocument(); expect(container.querySelector('iframe')).not.toBeInTheDocument(); + expect(container.querySelector('img')).not.toBeInTheDocument(); + + // Verify the text is still there but sanitized + expect(screen.getByText('Unsafe')).toBeInTheDocument(); }); }); diff --git a/src/components/TripViewer.tsx b/src/components/TripViewer.tsx index f962b88..3131d81 100644 --- a/src/components/TripViewer.tsx +++ b/src/components/TripViewer.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import { FiTrash2 as Trash2 } from 'react-icons/fi'; import { Logo } from '@/components/Logo'; import { Trip } from '@/types/trip'; @@ -10,7 +11,17 @@ interface TripViewerProps { onDelete: (id: string) => void; } -export const TripViewer: React.FC = ({ trip, onDelete }) => { +/** + * Custom sanitization schema. + * We block all iframes by default and only allow them via our custom 'a' component. + * We also block images to respect the 'No illustration' rule. + */ +const sanitizeSchema = { + ...defaultSchema, + tagNames: defaultSchema.tagNames?.filter((tag) => tag !== 'iframe' && tag !== 'img'), +}; + +export const TripViewer: React.FC = React.memo(({ trip, onDelete }) => { return (
@@ -22,45 +33,90 @@ export const TripViewer: React.FC = ({ trip, onDelete }) => { +
+ + From + +

{trip.start}

+
+
( - - {children} - + // Typography Scaling & Spacing Refinements + h3: ({ children }) => ( +

+ {children} +

), - iframe: ({ src, ...props }) => { + ul: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + p: ({ children }) => { + // If a child is a map (wrapped in our custom ), render as
    to avoid

    nesting + const hasMap = React.Children.toArray(children).some( + (child) => + React.isValidElement(child) && + child.type === 'a' && + (child.props as { href?: string }).href?.includes('google.com/maps'), + ); + return hasMap ? ( +

    {children}
    + ) : ( +

    {children}

    + ); + }, + iframe: () => null, + a: ({ href, children }) => { const isGoogleMap = - src?.startsWith('https://www.google.com/maps/embed') || - src?.startsWith('https://www.google.com/maps?') || - src?.startsWith('https://maps.google.com/'); + href?.startsWith('https://www.google.com/maps/embed') || + href?.startsWith('https://www.google.com/maps?') || + href?.startsWith('https://maps.google.com/') || + href?.includes('google.com/maps/dir/'); + + if (isGoogleMap && href) { + // Transform search/dir URL to embed URL if possible + let embedUrl = href; + if (href.includes('google.com/maps/dir/')) { + const parts = href.split('google.com/maps/dir/')[1].split('/'); + const saddr = encodeURIComponent(parts[0].trim()); + const daddr = encodeURIComponent(parts[1].trim()); + embedUrl = `https://www.google.com/maps?saddr=${saddr}&daddr=${daddr}&output=embed`; + } - if (!isGoogleMap) { return ( -
    - Blocked unsafe iframe: {src} -
    + + +