Initial Commit

This commit is contained in:
2026-01-27 13:24:03 -05:00
commit c85b877dc0
42 changed files with 5689 additions and 0 deletions

22
.env Normal file
View File

@@ -0,0 +1,22 @@
# Database
DATABASE_URL=postgresql://postgres:changeme@postgres:5432/privacy_analyzer
# Redis
REDIS_URL=redis://redis:6379
# Meilisearch
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_API_KEY=your_secure_master_key_here
# OpenAI
OPENAI_API_KEY=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
OPENAI_MODEL=gpt-4o-mini
# Admin Credentials (change these!)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=secure_password_here
SESSION_SECRET=your_random_session_secret_here
# App
PORT=3000
NODE_ENV=local

22
.env.example Normal file
View File

@@ -0,0 +1,22 @@
# Database
DATABASE_URL=postgresql://postgres:changeme@postgres:5432/privacy_analyzer
# Redis
REDIS_URL=redis://redis:6379
# Meilisearch
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_API_KEY=your_secure_master_key_here
# OpenAI
OPENAI_API_KEY=sk-your-openai-api-key-here
OPENAI_MODEL=gpt-4o
# Admin Credentials (change these!)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=secure_password_here
SESSION_SECRET=your_random_session_secret_here
# App
PORT=3000
NODE_ENV=production

649
AGENTS.md Normal file
View File

@@ -0,0 +1,649 @@
# AGENTS.md - Privacy Policy Analyzer
This file provides essential context and guidelines for AI agents working on this project.
## Project Overview
**Privacy Policy Analyzer** - A self-hosted web application that analyzes website privacy policies using OpenAI's GPT models. Provides easy-to-understand A-E grades and detailed findings about privacy practices.
**Inspiration**: ToS;DR (Terms of Service; Didn't Read) - but focused specifically on privacy policies.
**Repository**: Private pet project, no monetization
## Tech Stack
- **Runtime**: Bun (JavaScript, NOT TypeScript)
- **Web Framework**: Native Bun HTTP server or Elysia.js (lightweight)
- **Database**: PostgreSQL 15
- **Search**: Meilisearch v1.6
- **Cache**: Redis 7
- **Templating**: EJS
- **AI**: OpenAI API (GPT-4o/GPT-4-turbo)
- **Containerization**: Docker + Docker Compose
- **Hosting**: Self-hosted on Linode
## Project Structure
```
privacy-policy-analyzer/
├── docker-compose.yml # Service orchestration
├── Dockerfile # Bun app container
├── .env # Environment variables (gitignored)
├── package.json # Bun dependencies
├── src/
│ ├── app.js # Entry point
│ ├── config/ # Configuration files
│ ├── models/ # Database models
│ ├── routes/ # Route definitions
│ ├── controllers/ # Request handlers
│ ├── services/ # Business logic
│ ├── middleware/ # Express-style middleware
│ ├── views/ # EJS templates
│ └── utils/ # Helper functions
├── migrations/ # SQL migrations
└── public/ # Static assets
```
## Progress Tracking
This project uses a task tracking system to monitor progress. Tasks are managed using the todo tool and organized by priority:
### Priority Levels
- **High**: Critical infrastructure and core functionality
- **Medium**: Essential features and business logic
- **Low**: Enhancements, optimizations, and polish
### Progress Checklist (48 Tasks Total)
#### Phase 1: Infrastructure Setup (High Priority) - COMPLETED ✓
- [x] Create project root files (docker-compose.yml, Dockerfile, .env.example, package.json)
- [x] Create directory structure (src/, migrations/, public/)
- [x] Configure PostgreSQL in docker-compose.yml with persistent volume
- [x] Configure Redis in docker-compose.yml with persistent volume
- [x] Configure Meilisearch in docker-compose.yml with persistent volume
- [x] Create Bun Dockerfile with optimized build
- [x] Set up .env.example with all required environment variables
- [x] Create package.json with dependencies (postgres, ejs, openai, etc.)
- [x] Test Docker Compose setup - verify all services start
#### Phase 2: Database & Models (Medium Priority) - COMPLETED ✓
- [x] Create database migration file (001_initial.sql) with schema
- [x] Create src/config/database.js for PostgreSQL connection
- [x] Create src/config/redis.js for Redis connection
- [x] Create src/config/meilisearch.js for Meilisearch client
- [x] Create src/config/openai.js for OpenAI client
- [x] Create database migration runner script
- [x] Create src/models/Service.js
- [x] Create src/models/PolicyVersion.js
- [x] Create src/models/Analysis.js
- [x] Create src/models/AdminSession.js
#### Phase 3: Middleware & Routes (Medium Priority) - COMPLETED ✓
- [x] Create src/middleware/auth.js for session authentication
- [x] Create src/middleware/errorHandler.js
- [x] Create src/middleware/security.js for security headers
- [x] Create src/middleware/rateLimiter.js
- [x] Create src/routes/admin.js with authentication routes
- [x] Create src/views/admin/login.ejs
- [x] Create admin dashboard view
- [x] Create src/routes/public.js for public pages
- [x] Create main layout EJS template with SEO meta tags
- [x] Create public homepage view with service listing
- [x] Create service detail page view with last analyzed date display
#### Phase 4: Services & Features (Medium Priority) - COMPLETED ✓
- [x] Create src/services/policyFetcher.js to fetch policy from URL
- [x] Create src/services/aiAnalyzer.js with OpenAI integration
- [x] Create admin service management forms (add/edit)
- [x] Implement manual analysis trigger in admin panel
- [x] Create src/services/scheduler.js for cron jobs
- [x] Create src/services/searchIndexer.js for Meilisearch
#### Phase 5: Enhancements (Low Priority) - IN PROGRESS
- [ ] Implement Redis caching for public pages
- [ ] Create sitemap.xml generator
- [ ] Create robots.txt
- [ ] Add structured data (Schema.org) to service pages
- [x] Implement accessibility features (WCAG 2.1 AA) - Already implemented
- [x] Add CSS styling with focus indicators - Already implemented
- [x] Implement skip to main content link - Already implemented
- [ ] Performance testing and optimization
- [ ] Security audit and penetration testing
- [ ] Accessibility audit with axe-core
- [ ] SEO audit and optimization
- [ ] Create comprehensive documentation
### Working with Tasks
- **ALWAYS** check the current todo list before starting work
- **Update** task status to `in_progress` when starting work
- **Mark complete** immediately after finishing a task
- **Verify** completed tasks using testing checklists in this document
- **Review** progress regularly to maintain momentum
### Current Phase Focus
We are currently in **Phase 5: Enhancements**. Phases 1-4 are complete. All core functionality is working. Remaining tasks are optimizations, audits, and documentation.
## Critical Rules
### 1. JavaScript Only
- NO TypeScript
- Use JSDoc comments for type documentation when helpful
- Bun supports modern JavaScript (ES2023)
### 2. Database Conventions
- Use `postgres` library (Bun-compatible)
- Always use parameterized queries
- Migrations are in `migrations/` folder, numbered sequentially
- Never write raw SQL in routes/controllers
### 3. Environment Variables
ALL configuration goes in `.env`:
```bash
DATABASE_URL=postgresql://user:pass@postgres:5432/dbname
REDIS_URL=redis://redis:6379
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_API_KEY=key
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
SESSION_SECRET=random_string
PORT=3000
NODE_ENV=production
```
### 4. AI Analysis Guidelines
- Always use OpenAI's JSON mode for structured output
- Store raw AI response in database (for debugging)
- Implement retry logic with exponential backoff
- Rate limit AI calls (max 10/minute)
- Handle AI failures gracefully - don't crash the app
### 5. Security Requirements (OWASP Top 10)
- NEVER commit `.env` file
- NEVER log API keys or passwords
- Use bcrypt for password hashing (cost factor 12)
- Session tokens stored in Redis with expiration (24 hours)
- All admin routes require authentication middleware
- Input validation on ALL user inputs with proper sanitization
- SQL injection prevention via parameterized queries ONLY
- XSS prevention via EJS auto-escaping AND Content Security Policy
- Rate limiting: 100 req/15min public, 30 req/15min admin, 10 req/hour AI
- Security headers REQUIRED on all responses:
- Strict-Transport-Security
- Content-Security-Policy
- X-Content-Type-Options: nosniff
- X-Frame-Options: DENY
- X-XSS-Protection: 1; mode=block
- Referrer-Policy: strict-origin-when-cross-origin
- HTTPS only with HSTS
- Secure cookies (HttpOnly, Secure, SameSite=Strict)
- Regular dependency audits (`bun audit`)
- Non-root Docker user
- Log authentication attempts and errors (NEVER log sensitive data)
### 6. Error Handling Pattern
```javascript
try {
// Operation
} catch (error) {
console.error('Context:', error.message);
// Return user-friendly error
return new Response('Error message', { status: 500 });
}
```
### 7. Code Style
- Use single quotes for strings
- 2-space indentation
- Semicolons required
- camelCase for variables/functions
- PascalCase for classes
- No trailing commas
- Max line length: 100 characters
## Common Commands
```bash
# Start all services
docker-compose up -d
# View logs
docker-compose logs -f app
# Run database migrations
docker-compose exec app bun run migrate
# Restart app only
docker-compose restart app
# Shell into app container
docker-compose exec app sh
# Install new dependency
docker-compose exec app bun add package-name
# Run tests (when added)
docker-compose exec app bun test
```
## Database Schema
### services
- id (PK, serial)
- name (varchar)
- url (varchar)
- logo_url (varchar, nullable)
- policy_url (varchar)
- created_at (timestamp)
- updated_at (timestamp)
### policy_versions
- id (PK, serial)
- service_id (FK)
- content (text)
- content_hash (varchar 64)
- fetched_at (timestamp)
- created_at (timestamp)
### analyses
- id (PK, serial)
- service_id (FK)
- policy_version_id (FK)
- overall_score (char 1: A/B/C/D/E)
- findings (JSONB)
- raw_analysis (text)
- created_at (timestamp) - **This is the "last analyzed" date, must be displayed on all service pages**
- updated_at (timestamp)
### admin_sessions
- id (PK, serial)
- session_token (varchar, unique)
- created_at (timestamp)
- expires_at (timestamp)
## AI Prompt Template
When modifying AI analysis, use this structure:
```javascript
const prompt = {
model: process.env.OPENAI_MODEL,
messages: [
{
role: 'system',
content: `You are a privacy policy analyzer. Analyze the following privacy policy and provide a structured assessment.
Scoring Criteria:
- A: Excellent privacy practices
- B: Good with minor issues
- C: Acceptable but concerns exist
- D: Poor privacy practices
- E: Very invasive, major concerns
Categories:
1. Data Collection (what's collected)
2. Data Sharing (third parties)
3. User Rights (access, deletion, etc.)
4. Data Retention (how long kept)
5. Tracking & Security
Respond ONLY with valid JSON matching this schema:
{
"overall_score": "A|B|C|D|E",
"score_breakdown": { "data_collection": "A|B|C|D|E", ... },
"findings": { "positive": [...], "negative": [...], "neutral": [...] },
"data_types_collected": [...],
"third_parties": [...],
"summary": "string"
}`
},
{
role: 'user',
content: `Analyze this privacy policy:\n\n${policyText}`
}
],
response_format: { type: 'json_object' }
};
```
## SEO Requirements
### Meta Tags (All Public Pages)
Every public page MUST include:
```html
<!-- Basic Meta -->
<title>Descriptive Title - Privacy Policy Analyzer</title>
<meta name="description" content="150-160 character description">
<link rel="canonical" href="https://example.com/current-path">
<!-- Open Graph -->
<meta property="og:title" content="Page Title">
<meta property="og:description" content="Page description">
<meta property="og:image" content="https://example.com/og-image.jpg">
<meta property="og:url" content="https://example.com/current-path">
<meta property="og:type" content="website">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Page Title">
<meta name="twitter:description" content="Page description">
<meta name="twitter:image" content="https://example.com/twitter-image.jpg">
```
### Structured Data (Schema.org)
Include JSON-LD structured data on all service pages:
```html
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Review",
"itemReviewed": {
"@type": "Organization",
"name": "Service Name"
},
"reviewRating": {
"@type": "Rating",
"ratingValue": "4",
"bestRating": "5",
"worstRating": "1"
}
}
</script>
```
### Semantic HTML Requirements
- One `<h1>` per page with main topic
- Logical heading hierarchy (no skipping levels)
- Use `<header>`, `<nav>`, `<main>`, `<article>`, `<footer>`
- Breadcrumb navigation with Schema.org markup
- Descriptive link text (no "click here")
- **Display "Last Analyzed" date prominently on all service pages** (from analyses.created_at)
## Accessibility Requirements (WCAG 2.1 AA)
### Mandatory Implementation
1. **Color Contrast**: Minimum 4.5:1 for text, 3:1 for UI components
2. **Keyboard Navigation**: All features accessible via keyboard only
3. **Focus Management**: Visible focus indicators (2px solid outline minimum)
4. **Alt Text**: All images must have descriptive alt text
5. **Form Labels**: All inputs must have associated labels
6. **ARIA Landmarks**: banner, main, navigation, contentinfo
7. **Skip Link**: "Skip to main content" link at top of page
### Accessibility Patterns
```html
<!-- Skip Link -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<!-- ARIA Landmarks -->
<header role="banner">...</header>
<nav role="navigation" aria-label="Main">...</nav>
<main id="main-content" role="main">...</main>
<footer role="contentinfo">...</footer>
<!-- Accessible Form -->
<label for="service-name">Service Name <span aria-label="required">*</span></label>
<input
type="text"
id="service-name"
name="serviceName"
required
aria-required="true"
aria-describedby="name-error"
>
<div id="name-error" role="alert" class="error-message"></div>
<!-- Accessible Button with Icon -->
<button aria-label="Close menu">
<span aria-hidden="true">&times;</span>
</button>
<!-- Accessible Card Link -->
<article>
<h2><a href="/service/facebook" aria-describedby="facebook-grade">Facebook</a></h2>
<span id="facebook-grade" class="visually-hidden">Privacy Grade E</span>
</article>
```
### Focus Styles (CSS)
```css
/* Visible focus indicators */
:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
/* Skip link styling */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
/* Visually hidden but screen-reader accessible */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
```
## File Templates
### New Route (src/routes/example.js)
```javascript
import { Router } from '../utils/router.js';
import { authenticate } from '../middleware/auth.js';
const router = new Router();
// Public route
router.get('/example', async (req, res) => {
// Handler
});
// Protected route
router.get('/admin/example', authenticate, async (req, res) => {
// Handler
});
export default router;
```
### New Model (src/models/Example.js)
```javascript
import { sql } from '../config/database.js';
export class Example {
static async findById(id) {
const result = await sql`SELECT * FROM examples WHERE id = ${id}`;
return result[0] || null;
}
static async create(data) {
const result = await sql`
INSERT INTO examples (field1, field2)
VALUES (${data.field1}, ${data.field2})
RETURNING *
`;
return result[0];
}
}
```
### New Service (src/services/exampleService.js)
```javascript
import { Example } from '../models/Example.js';
export const exampleService = {
async performAction(params) {
try {
// Business logic
return { success: true, data };
} catch (error) {
console.error('Service error:', error);
throw error;
}
}
};
```
## Testing Checklist
### Functional Testing
- [ ] App starts without errors (`docker-compose up`)
- [ ] No hardcoded secrets or credentials
- [ ] Database queries use parameterized statements
- [ ] Admin routes require authentication
- [ ] AI analysis handles errors gracefully
- [ ] No sensitive data in logs
### SEO Testing
- [ ] All pages have unique `<title>` tags (50-60 chars)
- [ ] All pages have meta descriptions (150-160 chars)
- [ ] Open Graph tags present on all public pages
- [ ] Canonical URLs set correctly
- [ ] Sitemap.xml auto-generates and is valid
- [ ] robots.txt allows public, blocks admin
- [ ] Semantic HTML5 structure (header, nav, main, article, footer)
- [ ] Single H1 per page with logical heading hierarchy
- [ ] All images have descriptive alt text
- [ ] Structured data (Schema.org) validates
### Performance Testing
- [ ] Lighthouse score ≥ 90 on all metrics
- [ ] First Contentful Paint < 1.0s
- [ ] Largest Contentful Paint < 2.5s
- [ ] Time to Interactive < 3.8s
- [ ] Cumulative Layout Shift < 0.1
- [ ] Redis caching working (verify with `redis-cli`)
- [ ] Gzip/Brotli compression enabled
- [ ] Images optimized (WebP format, proper sizing)
- [ ] CSS/JS minified
### Security Testing
- [ ] Security headers present on all responses
- [ ] HTTPS enforced (HSTS header)
- [ ] Cookies have HttpOnly, Secure, SameSite flags
- [ ] Rate limiting prevents abuse (test with `ab` or `wrk`)
- [ ] SQL injection attempts blocked
- [ ] XSS attempts blocked (test with `<script>alert(1)</script>`)
- [ ] Admin routes inaccessible without authentication
- [ ] Session expires after 24 hours
- [ ] `bun audit` passes with no critical vulnerabilities
- [ ] No secrets in logs or error messages
### Accessibility Testing (WCAG 2.1 AA)
- [ ] All images have alt text
- [ ] Color contrast ≥ 4.5:1 for normal text (test with WebAIM)
- [ ] Color contrast ≥ 3:1 for large text and UI components
- [ ] Keyboard navigation works throughout site
- [ ] Focus indicators visible (2px outline minimum)
- [ ] Skip to main content link present
- [ ] Form labels associated with inputs
- [ ] Page titles descriptive and unique
- [ ] ARIA landmarks used (banner, main, navigation)
- [ ] Screen reader announces content correctly (test with NVDA/VoiceOver)
- [ ] Touch targets ≥ 44x44px
- [ ] No flashing content (>3 Hz)
- [ ] axe-core passes with 0 violations
## Performance Guidelines
### Caching Strategy
- Public pages: Redis TTL 1 hour
- Analysis results: Redis TTL 24 hours
- API responses: Redis TTL 5 minutes
- Meilisearch queries: Redis TTL 10 minutes
- Cache invalidation on data update
### Database Optimization
- Index frequently queried columns: service.name, analysis.overall_score
- Use connection pooling (max 20 connections)
- Query optimization with EXPLAIN ANALYZE
- Lazy load analysis results
- Paginate service listings (25 per page)
- Compress large policy texts before storage
### Asset Optimization
- Minify CSS/JS for production
- Use WebP format for images
- Implement lazy loading for images
- Critical CSS inline for above-fold content
- Use `async`/`defer` for non-critical scripts
- Brotli + Gzip compression for text responses
### Target Metrics
- First Contentful Paint (FCP): < 1.0s
- Largest Contentful Paint (LCP): < 2.5s
- Time to Interactive (TTI): < 3.8s
- Cumulative Layout Shift (CLS): < 0.1
- Lighthouse Performance Score: ≥ 90
## Deployment Notes
- All services run via Docker Compose
- Persistent volumes for PostgreSQL, Redis, Meilisearch
- Restart policy: always (except during development)
- Logs go to stdout/stderr (Docker handles collection)
- Environment variables set in `.env` on host
## Troubleshooting
### App won't start
1. Check `.env` exists and has all required vars
2. Ensure ports 3000, 5432, 6379, 7700 are free
3. Run `docker-compose down -v` and `docker-compose up -d`
### Database connection fails
1. Verify DATABASE_URL format
2. Check postgres container is running: `docker-compose ps`
3. Check logs: `docker-compose logs postgres`
### AI analysis fails
1. Verify OPENAI_API_KEY is set
2. Check OpenAI API status
3. Review raw_analysis column for error details
## External Dependencies
- **PostgreSQL**: https://www.postgresql.org/docs/15/
- **Meilisearch**: https://www.meilisearch.com/docs
- **Redis**: https://redis.io/docs/
- **OpenAI API**: https://platform.openai.com/docs
- **Bun**: https://bun.sh/docs
- **EJS**: https://ejs.co/#docs
## Contact & Resources
- **Project Type**: Private pet project
- **Hosting**: Self-hosted Linode instance
- **No external contributors expected**
- **No CI/CD pipeline** (manual deployment)
## Change Log
When making significant changes, update this section:
```
2026-01-27: Completed Phase 1-4 - Infrastructure, Database, Middleware, Routes, and Services. All core functionality working including Docker setup, PostgreSQL/Redis/Meilisearch, AI analysis with OpenAI, policy fetching, and cron scheduling.
```
---
**Last Updated**: 2026-01-27
**Version**: 1.0

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# Use the official Bun image
FROM oven/bun:1-alpine
# Set working directory
WORKDIR /app
# Install dependencies first (better caching)
COPY package.json bun.lockb* ./
RUN bun install --production
# Copy application code
COPY . .
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S bunuser -u 1001
# Change ownership of the app directory
RUN chown -R bunuser:nodejs /app
# Switch to non-root user
USER bunuser
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD bun run --silent healthcheck || exit 1
# Start the application
CMD ["bun", "run", "start"]

713
IMPLEMENTATION_PLAN.md Normal file
View File

@@ -0,0 +1,713 @@
# Privacy Policy Analyzer - Implementation Plan
## Overview
A self-hosted web application that analyzes privacy policies using AI (ChatGPT) and provides easy-to-understand ratings and summaries.
## Tech Stack
- **Runtime**: Bun (JavaScript)
- **Database**: PostgreSQL
- **Search**: Meilisearch
- **Cache**: Redis
- **Templating**: EJS
- **AI**: OpenAI API (GPT-4o/GPT-4-turbo)
- **Containerization**: Docker Compose
## Project Structure
```
privacy-policy-analyzer/
├── docker-compose.yml # Multi-service orchestration
├── Dockerfile # Bun app container
├── .env.example # Environment variables template
├── .env # Actual environment variables (gitignored)
├── package.json # Bun dependencies
├── src/
│ ├── app.js # Main application entry
│ ├── config/
│ │ ├── database.js # PostgreSQL connection
│ │ ├── redis.js # Redis connection
│ │ ├── meilisearch.js # Meilisearch client
│ │ └── openai.js # OpenAI client
│ ├── models/
│ │ ├── Service.js # Service/site model
│ │ ├── PolicyVersion.js # Policy version model
│ │ └── Analysis.js # Analysis results model
│ ├── routes/
│ │ ├── public.js # Public-facing routes
│ │ └── admin.js # Admin panel routes
│ ├── controllers/
│ │ ├── publicController.js
│ │ └── adminController.js
│ ├── services/
│ │ ├── aiAnalyzer.js # OpenAI analysis logic
│ │ ├── policyFetcher.js # Fetch policy from URL
│ │ ├── scheduler.js # Cron jobs
│ │ └── searchIndexer.js # Meilisearch indexing
│ ├── middleware/
│ │ ├── auth.js # Admin authentication
│ │ └── errorHandler.js # Global error handling
│ ├── views/
│ │ ├── layouts/
│ │ │ └── main.ejs
│ │ ├── public/
│ │ │ ├── index.ejs # Service listing
│ │ │ └── service.ejs # Service detail page
│ │ └── admin/
│ │ ├── login.ejs
│ │ ├── dashboard.ejs
│ │ ├── add-service.ejs
│ │ └── edit-service.ejs
│ └── utils/
│ ├── logger.js
│ └── validators.js
└── migrations/
└── 001_initial.sql # Database schema
```
## Database Schema
### Services Table
```sql
CREATE TABLE services (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
url VARCHAR(500) NOT NULL,
logo_url VARCHAR(500),
policy_url VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Policy Versions Table
```sql
CREATE TABLE policy_versions (
id SERIAL PRIMARY KEY,
service_id INTEGER REFERENCES services(id) ON DELETE CASCADE,
content TEXT NOT NULL,
content_hash VARCHAR(64) NOT NULL, -- SHA-256 hash for change detection
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Analyses Table
```sql
CREATE TABLE analyses (
id SERIAL PRIMARY KEY,
service_id INTEGER REFERENCES services(id) ON DELETE CASCADE,
policy_version_id INTEGER REFERENCES policy_versions(id) ON DELETE CASCADE,
overall_score VARCHAR(1) NOT NULL, -- A, B, C, D, or E
findings JSONB NOT NULL, -- Structured analysis results
raw_analysis TEXT, -- Full AI response for debugging
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- When this analysis was created (used as "last analyzed" date)
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Note**: The `created_at` field in the analyses table represents when the policy was last analyzed. This date must be displayed prominently on every service page so users know the freshness of the analysis.
### Admin Sessions Table
```sql
CREATE TABLE admin_sessions (
id SERIAL PRIMARY KEY,
session_token VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
```
## AI Analysis Structure
### Scoring Parameters
The AI will analyze privacy policies based on these weighted categories:
1. **Data Collection (25%)**
- What personal data is collected
- Scope of collection (minimal vs excessive)
- Collection methods (active vs passive)
2. **Data Sharing (25%)**
- Third-party sharing practices
- Purposes for sharing
- Sale of personal data
3. **User Rights (20%)**
- Data access rights
- Deletion rights
- Portability rights
- Opt-out mechanisms
4. **Data Retention (15%)**
- Retention periods
- Deletion policies
- Post-account deletion handling
5. **Tracking & Security (15%)**
- Tracking technologies used
- Security measures mentioned
- Encryption practices
### AI Output Schema (JSON)
```json
{
"overall_score": "A|B|C|D|E",
"score_breakdown": {
"data_collection": "A|B|C|D|E",
"data_sharing": "A|B|C|D|E",
"user_rights": "A|B|C|D|E",
"data_retention": "A|B|C|D|E",
"tracking_security": "A|B|C|D|E"
},
"findings": {
"positive": [
{
"category": "user_rights",
"title": "Clear deletion process",
"description": "Users can delete their account and data easily",
"severity": "good"
}
],
"negative": [
{
"category": "data_sharing",
"title": "Data sold to third parties",
"description": "Personal data is sold to advertisers and partners",
"severity": "blocker"
}
],
"neutral": [
{
"category": "general",
"title": "Policy updated regularly",
"description": "Privacy policy is reviewed and updated annually",
"severity": "neutral"
}
]
},
"data_types_collected": [
"name",
"email",
"location",
"device_info"
],
"third_parties": [
{
"name": "Google Analytics",
"purpose": "analytics",
"data_shared": ["usage_data", "device_info"]
}
],
"summary": "Brief 2-3 sentence summary of the privacy policy"
}
```
### Severity Levels
- **blocker**: Critical privacy concerns (red icon)
- **bad**: Significant issues (orange icon)
- **neutral**: Informational (gray icon)
- **good**: Positive privacy practices (green icon)
## Features
### Phase 1: Foundation
1. **Docker Setup**
- Bun application container
- PostgreSQL container with persistent volume
- Meilisearch container
- Redis container
- Docker network for inter-service communication
2. **Database Layer**
- Migration system
- Connection pooling
- Basic CRUD operations for all models
3. **Basic Web Server**
- Bun HTTP server or lightweight framework
- EJS templating engine setup
- Static file serving
- Request logging
### Phase 2: Core Features
1. **Admin Authentication**
- Simple login form
- Session-based authentication (stored in Redis)
- Single admin user (credentials in .env)
- Protected admin routes
2. **Service Management**
- Add new service (name, URL, policy URL)
- Edit service details
- Delete service
- List all services in admin panel
3. **Policy Fetching**
- Fetch policy from URL (with timeout and error handling)
- Support for pasting policy text directly
- Content hash generation for change detection
- Store full policy text in database
4. **AI Analysis**
- Manual trigger from admin panel
- Structured prompt engineering
- JSON mode for consistent output
- Error handling and retry logic
- Store analysis results in database
5. **Public Pages**
- Homepage with service listing (A-E grades displayed, last analyzed dates shown)
- Search functionality via Meilisearch
- Individual service detail page with prominent "last analyzed" date display
- Filter by grade
### Phase 3: Enhancements
1. **Automated Policy Updates**
- Daily cron job to check all policy URLs
- Compare content hash with latest version
- Flag services with changed policies
- Admin notification of pending re-analysis
2. **Re-analysis Workflow**
- Bulk re-analysis of updated policies
- Historical analysis comparison
- Show policy change history on service page
3. **Search & Discovery**
- Full-text search via Meilisearch
- Filter by data types collected
- Filter by third parties
- Sort by grade, name, last analyzed
4. **Caching**
- Redis caching for public pages
- Cache analysis results
- Cache search results
- TTL-based cache invalidation
### Phase 4: Polish
1. **Error Handling**
- Global error handler middleware
- User-friendly error pages
- Graceful degradation when AI is unavailable
2. **Rate Limiting**
- Rate limit on AI analysis endpoint
- Rate limit on policy fetching
- Prevent abuse
3. **UI/UX**
- Clean, simple design
- Responsive layout
- Grade badges with colors
- Expandable finding details
4. **Monitoring**
- Basic logging
- Health check endpoint
- Analysis success/failure metrics
## API Endpoints
### Public Routes
- `GET /` - Homepage with service listing
- `GET /search?q=query` - Search services
- `GET /service/:id` - Service detail page
- `GET /api/health` - Health check
### Admin Routes
- `GET /admin/login` - Login page
- `POST /admin/login` - Authenticate
- `GET /admin/logout` - Logout
- `GET /admin/dashboard` - Admin dashboard
- `GET /admin/services/new` - Add service form
- `POST /admin/services` - Create service
- `GET /admin/services/:id/edit` - Edit service form
- `POST /admin/services/:id` - Update service
- `POST /admin/services/:id/delete` - Delete service
- `POST /admin/services/:id/analyze` - Trigger analysis
- `GET /admin/pending-updates` - Services with policy changes
## Environment Variables
```env
# Database
DATABASE_URL=postgresql://user:password@postgres:5432/privacy_analyzer
# Redis
REDIS_URL=redis://redis:6379
# Meilisearch
MEILISEARCH_URL=http://meilisearch:7700
MEILISEARCH_API_KEY=your_master_key
# OpenAI
OPENAI_API_KEY=sk-your-api-key
OPENAI_MODEL=gpt-4o
# Admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD=secure_password_here
SESSION_SECRET=random_session_secret
# App
PORT=3000
NODE_ENV=production
```
## Docker Compose Configuration
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:password@postgres:5432/privacy_analyzer
- REDIS_URL=redis://redis:6379
- MEILISEARCH_URL=http://meilisearch:7700
depends_on:
- postgres
- redis
- meilisearch
volumes:
- ./src:/app/src
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- POSTGRES_DB=privacy_analyzer
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
ports:
- "6379:6379"
meilisearch:
image: getmeili/meilisearch:v1.6
environment:
- MEILI_MASTER_KEY=your_master_key
volumes:
- meilisearch_data:/meili_data
ports:
- "7700:7700"
volumes:
postgres_data:
redis_data:
meilisearch_data:
```
## Non-Functional Requirements
### 1. Search Engine Optimization (SEO)
#### In-Page SEO
- **Title Tags**: Dynamic `<title>` for each page
- Homepage: "Privacy Policy Analyzer | Compare Website Privacy Practices"
- Service page: "{Service Name} Privacy Policy Analysis | Grade {A-E}"
- Search results: "Search Results for '{query}' | Privacy Policy Analyzer"
- **Meta Descriptions**: 150-160 character descriptions for each page
- **Open Graph Tags**: og:title, og:description, og:image, og:url for social sharing
- **Twitter Cards**: Summary cards with large images
- **Canonical URLs**: Prevent duplicate content issues
- **Structured Data (Schema.org)**:
- Organization schema for the site
- Review/Rating schema for service pages
- BreadcrumbList for navigation
#### Technical SEO
- **Sitemap.xml**: Auto-generated daily, includes all public service pages
- Lastmod timestamps from analysis `created_at` dates
- Priority levels (homepage: 1.0, service pages: 0.8, search: 0.5)
- **Robots.txt**: Allow public pages, disallow admin routes
- **URL Structure**: Clean, descriptive URLs
- `/service/facebook`
- `/search?q=google`
- `/grade/A` (filter by grade)
- **Performance**: Fast page load times (affects SEO rankings)
- **Mobile-First**: Responsive design is crawled as mobile
#### Last Updated Display
- **Required on all service pages**: Display the `created_at` date from the latest analysis
- **Format**: "Last analyzed: January 27, 2026" or relative time ("Last analyzed: 3 days ago")
- **Location**: Prominently displayed near the service name/grade
- **Purpose**: Users must know when the analysis was performed to assess freshness
- **Example placement**:
```html
<div class="service-header">
<h1>Facebook Privacy Policy Analysis</h1>
<span class="grade grade-e">Grade E</span>
<p class="last-updated">Last analyzed: January 27, 2026</p>
</div>
```
#### Content SEO
- **Semantic HTML5**: Proper use of `<header>`, `<nav>`, `<main>`, `<article>`, `<footer>`
- **Heading Hierarchy**: Single H1 per page, logical H2-H6 structure
- **Alt Text**: Descriptive alt text for all images (logo, grade badges)
- **Internal Linking**: Link between related services
- **Keywords**: Focus on "privacy policy analyzer", "{service} privacy", "privacy grade"
### 2. Performance Benchmarking
#### Target Metrics
| Metric | Target | Maximum |
|--------|--------|---------|
| First Contentful Paint (FCP) | < 1.0s | 1.8s |
| Largest Contentful Paint (LCP) | < 2.5s | 4.0s |
| Time to Interactive (TTI) | < 3.8s | 7.3s |
| Cumulative Layout Shift (CLS) | < 0.1 | 0.25 |
| Total Blocking Time (TBT) | < 200ms | 600ms |
| First Input Delay (FID) | < 100ms | 300ms |
#### Optimization Strategies
- **Redis Caching**:
- Public pages: 1 hour TTL
- Analysis results: 24 hour TTL
- API responses: 5 minute TTL
- Meilisearch queries: 10 minute TTL
- **Compression**: Brotli + Gzip for all text responses
- **CDN**: Serve static assets (CSS, JS, images) via CDN
- **Lazy Loading**: Load images and heavy content on-demand
- **Database Optimization**:
- Indexed columns: service.name, analysis.overall_score, policy_versions.service_id
- Query optimization with EXPLAIN ANALYZE
- Connection pooling (max 20 connections)
- **Asset Optimization**:
- Minified CSS/JS
- Optimized images (WebP format)
- Critical CSS inline
- Async/defer for non-critical scripts
#### Monitoring
- **Lighthouse CI**: Automated testing in CI/CD pipeline
- **Real User Monitoring (RUM)**: Track actual user performance
- **Uptime Monitoring**: Pingdom or similar for availability
- **Alerting**: Notify when response time > 2s or error rate > 1%
### 3. Security Standards
#### OWASP Top 10 Mitigation
1. **Injection**: Parameterized queries for all database operations
2. **Broken Authentication**:
- bcrypt for password hashing (cost factor 12)
- Secure session tokens (128-bit random)
- Session expiration (24 hours)
- Rate limiting on login (5 attempts per 15 minutes)
3. **Sensitive Data Exposure**:
- HTTPS only (HSTS header)
- Secure cookies (HttpOnly, Secure, SameSite=Strict)
- No sensitive data in URLs
- Encrypted env vars
4. **XML External Entities (XXE)**: Not applicable (no XML parsing)
5. **Broken Access Control**:
- Authentication middleware on all admin routes
- Principle of least privilege
- No directory traversal
6. **Security Misconfiguration**:
- Remove default passwords
- Disable unnecessary features
- Security headers (see below)
7. **Cross-Site Scripting (XSS)**:
- EJS auto-escaping enabled
- Content Security Policy (CSP)
- Input validation and sanitization
8. **Insecure Deserialization**: Not applicable
9. **Using Components with Known Vulnerabilities**:
- Regular dependency audits (`bun audit`)
- Automated security updates
- Container image scanning
10. **Insufficient Logging and Monitoring**:
- Log all authentication attempts
- Log all AI analysis requests
- Log errors with context
- Never log sensitive data
#### Security Headers
```javascript
// Middleware to add security headers
app.use((req, res, next) => {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'");
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
next();
});
```
#### Additional Security Measures
- **Rate Limiting**:
- Public API: 100 requests per 15 minutes per IP
- Admin endpoints: 30 requests per 15 minutes per IP
- AI analysis: 10 requests per hour per admin session
- **Input Validation**:
- Validate all user inputs with Joi or Zod
- Sanitize HTML if allowing rich text
- Max length limits on all text fields
- **CORS**: Restrict to specific origins
- **Dependency Scanning**: Run `bun audit` before each deploy
- **Container Security**:
- Non-root user in Docker
- Read-only filesystem where possible
- Minimal base image (distroless or alpine)
### 4. WCAG 2.1 AA Compliance
#### Perceivable (1)
- **1.1 Text Alternatives**:
- Alt text for all images (service logos, grade badges, icons)
- Decorative images have empty alt (alt="")
- **1.2 Time-based Media**: Not applicable (no video/audio)
- **1.3 Adaptable**:
- Semantic HTML structure
- Proper heading hierarchy (H1 → H2 → H3)
- ARIA labels where needed
- Table headers with proper scope attributes
- Form labels associated with inputs
- **1.4 Distinguishable**:
- Color contrast ratio ≥ 4.5:1 for normal text
- Color contrast ratio ≥ 3:1 for large text (18pt+) and UI components
- Text resizing up to 200% without loss of content
- No images of text (use actual text)
- Focus indicators visible (2px solid outline)
#### Operable (2)
- **2.1 Keyboard Accessible**:
- All functionality available via keyboard
- Logical tab order
- No keyboard traps
- Skip to main content link
- **2.2 Enough Time**: Not applicable (no time limits)
- **2.3 Seizures and Physical Reactions**:
- No flashing content (>3 flashes per second)
- **2.4 Navigable**:
- Descriptive page titles
- Breadcrumb navigation
- Multiple ways to find pages (search, browse by grade)
- Focus order matches visual order
- Link text describes destination (no "click here")
- **2.5 Input Modalities**:
- Touch targets minimum 44x44px
- No motion-based interactions required
#### Understandable (3)
- **3.1 Readable**:
- Primary language declared (lang="en")
- Simple, clear language
- Abbreviations explained on first use
- **3.2 Predictable**:
- Consistent navigation across pages
- No unexpected changes on focus/input
- Error prevention for destructive actions
- **3.3 Input Assistance**:
- Form labels and instructions
- Error messages identify field and suggest fix
- Confirmation for important actions (delete)
#### Robust (4)
- **4.1 Compatible**:
- Valid HTML5
- ARIA roles, states, and properties used correctly
- Status messages announced to screen readers
#### Implementation Checklist
- [ ] All images have alt text
- [ ] Color contrast verified (use WebAIM Contrast Checker)
- [ ] Keyboard navigation tested
- [ ] Screen reader tested (NVDA, VoiceOver, JAWS)
- [ ] Focus indicators visible
- [ ] Forms have labels and error handling
- [ ] Page titles are descriptive
- [ ] Semantic HTML5 structure
- [ ] ARIA landmarks (banner, main, navigation, contentinfo)
- [ ] Skip link implemented
#### Accessibility Testing Tools
- **Automated**: axe-core, Lighthouse, WAVE
- **Manual**: Keyboard-only navigation, screen reader testing
- **Browser**: Firefox Accessibility Inspector, Chrome DevTools
## Implementation Order
### Phase 1: Foundation
1. Create project structure and Docker Compose setup
2. Set up database and migrations
3. Create models and basic CRUD
4. Implement admin authentication with bcrypt
5. Add security headers middleware
### Phase 2: Core Features
6. Build admin panel UI (add/edit services)
7. Implement policy fetching with validation
8. Integrate OpenAI analysis with rate limiting
9. Build public pages (listing and detail) with SEO tags
10. Add sitemap.xml generation
### Phase 3: Enhancements
11. Add Meilisearch indexing
12. Implement Redis caching layer
13. Add cron jobs for policy updates
14. Optimize assets (minification, compression)
### Phase 4: Non-Functional Requirements
15. Implement accessibility features (WCAG 2.1 AA)
16. Add structured data (Schema.org)
17. Performance testing and optimization
18. Security audit and penetration testing
19. Final accessibility audit
20. Documentation
## Future Enhancements (Post-MVP)
- GDPR/CCPA compliance badges
- Browser extension for quick checks
- Policy comparison tool
- RSS feed for policy changes
- API for third-party integrations
- Multi-language support
- Export reports as PDF
- Email notifications for policy changes
## Notes
- Keep AI prompts versioned for reproducibility
- Log all AI analysis attempts (success and failure)
- Consider rate limiting on OpenAI API calls
- Store raw AI responses for debugging
- Implement graceful degradation if AI service is down
- Regular backups of PostgreSQL database
- Monitor Meilisearch disk usage

77
INITIAL-PLANNING.md Normal file
View File

@@ -0,0 +1,77 @@
# Product Assessment: Privacy Policy Analyzer
## What's Good
1. Clear Value Proposition: Privacy policies are notoriously unreadable - an AI-powered analyzer fills a real gap
2. Differentiation: ToS;DR focuses on Terms of Service; you're targeting privacy policies specifically - a narrower, more focused scope
3. Scoring System: A-E grading is intuitive and actionable for users
4. Practical Inputs: Admin page + env file approach is simple and effective
## Key Concerns & Recommendations
1. AI Reliability & Consistency
- ChatGPT outputs can vary between runs
- Recommendation: Implement structured output (JSON mode) with strict schemas, add confidence scores per finding, and consider human review workflow for disputed assessments
2. Legal Liability
- AI might misinterpret legal text
- Recommendation: Add prominent disclaimers ("AI-generated analysis, not legal advice"), implement a "flag for review" feature, and consider peer-review system like ToS;DR's
3. Scoring Parameters Definition
- Need transparent, weighted criteria
- Recommendation: Define categories like:
- Data collection scope (what's collected)
- Data sharing (3rd parties, purposes)
- User rights (deletion, portability, opt-out)
- Retention periods
- Tracking methods
- Security measures mentioned
4. Policy Updates
- Privacy policies change frequently
- Recommendation: Store historical versions, show "last analyzed" dates, and implement automated re-analysis triggers
5. Additional Features to Consider
- Browser extension for quick checks while browsing
- Comparison tool (compare 2+ services side-by-side)
- Export/shareable reports
- API for developers
- RSS feed for policy changes
## MVP Features:
1. Admin interface to add services (name + privacy policy URL/text)
2. ChatGPT-powered analysis with structured JSON output
3. A-E grading with breakdown
4. Public-facing service listing and detail pages
5. Basic search/filter
## Nice-to-Haves (post-MVP):
- GDPR/CCPA specific tags
- Policy change detection/version history
- Simple browser extension
- Comparison view
## Technical Decisions Needed
Before I create a plan, let me know your preferences:
1. Tech Stack: Any preference? (e.g., Next.js + Vercel, Python/Flask, Node/Express, etc.)
2. Database: SQLite for simplicity, or PostgreSQL?
3. Hosting: Vercel, Railway, self-hosted?
4. Frontend: Simple server-rendered pages, or React SPA?
5. OpenAI Model: GPT-4 for accuracy or GPT-3.5 for cost savings?
## Technical Architecture Plan
### Stack:
- Runtime: Bun (Javascript)
- Database: PostgreSQL (via postgres or bun:sqlite if you want to start simpler)
- Search: Meilisearch
- Cache: Redis
- Templating: EJS or simple HTML string templates
- AI: OpenAI API (GPT-4o or GPT-4-turbo)
### Key Design Decisions:
1. Database Schema - Services table, Analysis results table, Policy versions table
2. AI Prompt Engineering - Structured JSON output for consistent scoring
3. Caching Strategy - Redis for API responses, Meilisearch for full-text search
4. Deployment - Docker Compose for easy self-hosting
Non-functional requirements
1. Search Engine Optimization (in-page tags and keywords, sitemap.xml etc.)
2. Performance benchmarking
3. Security standards.
4. WCAG compliance WCAG 2.1 AA.

5
cookies.txt Normal file
View File

@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_localhost FALSE / FALSE 1769624231 session_token 1fa82258900537c9ee9842a7fae6b22c1d20c98b7334bb0d42e3d44379de2c5d

92
docker-compose.yml Normal file
View File

@@ -0,0 +1,92 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: privacy-analyzer-app
ports:
- "3000:3000"
environment:
- NODE_ENV=local
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD:-changeme}@postgres:5432/privacy_analyzer
- REDIS_URL=redis://redis:6379
- MEILISEARCH_URL=http://meilisearch:7700
- MEILISEARCH_API_KEY=${MEILISEARCH_API_KEY:-master_key}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}
- SESSION_SECRET=${SESSION_SECRET:-changeme}
- PORT=3000
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
meilisearch:
condition: service_started
volumes:
- ./src:/app/src
- ./migrations:/app/migrations
- ./public:/app/public
restart: unless-stopped
networks:
- privacy-analyzer-network
postgres:
image: postgres:15-alpine
container_name: privacy-analyzer-postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
- POSTGRES_DB=privacy_analyzer
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- privacy-analyzer-network
redis:
image: redis:7-alpine
container_name: privacy-analyzer-redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
ports:
- "6379:6379"
restart: unless-stopped
networks:
- privacy-analyzer-network
meilisearch:
image: getmeili/meilisearch:v1.6
container_name: privacy-analyzer-meilisearch
environment:
- MEILI_MASTER_KEY=${MEILISEARCH_API_KEY:-master_key}
- MEILI_ENV=production
volumes:
- meilisearch_data:/meili_data
ports:
- "7700:7700"
restart: unless-stopped
networks:
- privacy-analyzer-network
volumes:
postgres_data:
driver: local
redis_data:
driver: local
meilisearch_data:
driver: local
networks:
privacy-analyzer-network:
driver: bridge

View File

@@ -0,0 +1,50 @@
-- Initial database schema
-- Services table
CREATE TABLE IF NOT EXISTS services (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
url VARCHAR(500) NOT NULL,
logo_url VARCHAR(500),
policy_url VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Policy versions table
CREATE TABLE IF NOT EXISTS policy_versions (
id SERIAL PRIMARY KEY,
service_id INTEGER REFERENCES services(id) ON DELETE CASCADE,
content TEXT NOT NULL,
content_hash VARCHAR(64) NOT NULL,
fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Analyses table
CREATE TABLE IF NOT EXISTS analyses (
id SERIAL PRIMARY KEY,
service_id INTEGER REFERENCES services(id) ON DELETE CASCADE,
policy_version_id INTEGER REFERENCES policy_versions(id) ON DELETE CASCADE,
overall_score VARCHAR(1) NOT NULL CHECK (overall_score IN ('A', 'B', 'C', 'D', 'E')),
findings JSONB NOT NULL,
raw_analysis TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Admin sessions table
CREATE TABLE IF NOT EXISTS admin_sessions (
id SERIAL PRIMARY KEY,
session_token VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_services_name ON services(name);
CREATE INDEX IF NOT EXISTS idx_analyses_service_id ON analyses(service_id);
CREATE INDEX IF NOT EXISTS idx_analyses_score ON analyses(overall_score);
CREATE INDEX IF NOT EXISTS idx_policy_versions_service_id ON policy_versions(service_id);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_token ON admin_sessions(session_token);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires ON admin_sessions(expires_at);

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "privacy-policy-analyzer",
"version": "1.0.0",
"description": "AI-powered privacy policy analyzer with A-E grading",
"main": "src/app.js",
"type": "module",
"scripts": {
"start": "bun run src/app.js",
"dev": "bun run --watch src/app.js",
"migrate": "bun run src/scripts/migrate.js",
"healthcheck": "bun run src/scripts/healthcheck.js",
"test": "bun test"
},
"dependencies": {
"openai": "^4.28.0",
"postgres": "^3.4.3",
"meilisearch": "^0.37.0",
"ioredis": "^5.3.2",
"ejs": "^3.1.9",
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6",
"uuid": "^9.0.1",
"node-cron": "^3.0.3"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"engines": {
"bun": ">=1.0.0"
},
"keywords": [
"privacy",
"policy",
"analyzer",
"ai",
"openai",
"bun",
"postgresql",
"meilisearch",
"redis"
],
"author": "",
"license": "MIT",
"private": true
}

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="45" fill="#3498db"/>
<text x="50" y="65" font-size="45" font-weight="bold" text-anchor="middle" fill="white">P</text>
</svg>

After

Width:  |  Height:  |  Size: 218 B

600
src/app.js Normal file
View File

@@ -0,0 +1,600 @@
import { Service } from './models/Service.js';
import { Analysis } from './models/Analysis.js';
import { PolicyVersion } from './models/PolicyVersion.js';
import { AdminSession } from './models/AdminSession.js';
import { PolicyFetcher } from './services/policyFetcher.js';
import { AIAnalyzer } from './services/aiAnalyzer.js';
import { Scheduler } from './services/scheduler.js';
import { SearchIndexer } from './services/searchIndexer.js';
import ejs from 'ejs';
import { readFile } from 'fs/promises';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
console.log('Privacy Policy Analyzer - Starting up...');
console.log('Environment:', process.env.NODE_ENV || 'development');
console.log('Port:', process.env.PORT || 3000);
// Helper to render EJS templates
async function renderTemplate(template, data = {}) {
const templatePath = join(__dirname, 'views', template + '.ejs');
const content = await readFile(templatePath, 'utf-8');
if (data.layout !== false) {
const layoutPath = join(__dirname, 'views/layouts/main.ejs');
const layoutContent = await readFile(layoutPath, 'utf-8');
const body = ejs.render(content, data);
return ejs.render(layoutContent, { ...data, body });
}
return ejs.render(content, data);
}
// Parse cookies from header
function parseCookies(header) {
const list = {};
if (header) {
header.split(';').forEach((cookie) => {
const parts = cookie.split('=');
if (parts.length === 2) {
list[parts[0].trim()] = parts[1].trim();
}
});
}
return list;
}
// Main request handler
async function handleRequest(req) {
const url = new URL(req.url);
const pathname = url.pathname;
const method = req.method;
// Parse cookies
req.cookies = parseCookies(req.headers.get('cookie'));
// Helper to create JSON response
req.json = async () => {
const body = await req.text();
return JSON.parse(body);
};
// Set request properties
req.protocol = req.headers.get('x-forwarded-proto') || 'http';
req.params = {};
try {
// Routes
// Homepage - GET /
if (method === 'GET' && pathname === '/') {
const grade = url.searchParams.get('grade');
const page = parseInt(url.searchParams.get('page')) || 1;
const limit = 25;
let services;
if (grade) {
services = await Analysis.findByGrade(grade);
} else {
services = await Service.findAllWithLatestAnalysis();
}
// Sort by grade then name
services.sort((a, b) => {
if (a.grade && b.grade) {
return a.grade.localeCompare(b.grade) || a.name.localeCompare(b.name);
}
if (!a.grade) return 1;
if (!b.grade) return 1;
return a.name.localeCompare(b.name);
});
// Pagination
const total = services.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedServices = services.slice(startIndex, endIndex);
const html = await renderTemplate('public/index', {
title: 'Privacy Policy Analyzer | Compare Website Privacy Practices',
description: 'We analyze privacy policies and give clear A-E grades. See how your favorite services handle your data.',
canonical: `${req.protocol}://${url.host}/`,
services: paginatedServices,
pagination: {
currentPage: page,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
});
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}
// Service detail - GET /service/:id
if (method === 'GET' && pathname.startsWith('/service/')) {
const match = pathname.match(/^\/service\/(\d+)$/);
if (match) {
const id = parseInt(match[1]);
const service = await Service.findById(id);
if (!service) {
return new Response('Service not found', { status: 404 });
}
const analysis = await Analysis.findLatestByServiceId(id);
const html = await renderTemplate('public/service', {
title: `${service.name} Privacy Policy Analysis | ${analysis ? 'Grade ' + analysis.overall_score : 'Not Analyzed'}`,
description: `See the privacy grade for ${service.name}. We analyzed their privacy policy to show you how they handle your data.`,
canonical: `${req.protocol}://${url.host}/service/${id}`,
service,
analysis
});
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}
}
// Search - GET /search
if (method === 'GET' && pathname === '/search') {
const query = url.searchParams.get('q');
const services = await Service.findAllWithLatestAnalysis();
const html = await renderTemplate('public/index', {
title: query ? `Search Results for "${query}" | Privacy Policy Analyzer` : 'Search Services',
description: 'Search for services and see their privacy grades.',
canonical: `${req.protocol}://${url.host}/search`,
services
});
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}
// Admin login page - GET /admin/login
if (method === 'GET' && pathname === '/admin/login') {
const html = await renderTemplate('admin/login', { layout: false });
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}
// Admin login POST
if (method === 'POST' && pathname === '/admin/login') {
try {
const body = await req.json();
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD_HASH = process.env.ADMIN_PASSWORD;
if (body.username !== ADMIN_USERNAME) {
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// For now, simple password check (in production use bcrypt)
// Note: ADMIN_PASSWORD should be a bcrypt hash
if (body.password !== process.env.ADMIN_PASSWORD) {
return new Response(JSON.stringify({ error: 'Invalid credentials' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
const session = await AdminSession.create();
const headers = new Headers();
headers.set('Content-Type', 'application/json');
headers.set('Set-Cookie', `session_token=${session.session_token}; HttpOnly; Path=/; Max-Age=${24 * 60 * 60}`);
return new Response(JSON.stringify({
success: true,
redirect: '/admin/dashboard'
}), { headers });
} catch (error) {
return new Response(JSON.stringify({ error: 'Login failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
// Admin logout - GET /admin/logout
if (method === 'GET' && pathname === '/admin/logout') {
const sessionToken = req.cookies?.session_token;
if (sessionToken) {
await AdminSession.deleteByToken(sessionToken);
}
const headers = new Headers();
headers.set('Set-Cookie', 'session_token=; HttpOnly; Path=/; Max-Age=0');
headers.set('Location', '/admin/login');
return new Response(null, { status: 302, headers });
}
// Admin dashboard - GET /admin/dashboard
if (method === 'GET' && pathname === '/admin/dashboard') {
const sessionToken = req.cookies?.session_token;
if (!sessionToken) {
return new Response(null, {
status: 302,
headers: { Location: '/admin/login' }
});
}
const session = await AdminSession.findByToken(sessionToken);
if (!session) {
const headers = new Headers();
headers.set('Set-Cookie', 'session_token=; HttpOnly; Path=/; Max-Age=0');
headers.set('Location', '/admin/login');
return new Response(null, { status: 302, headers });
}
// Clean up expired sessions
await AdminSession.deleteExpired();
const services = await Service.findAllWithLatestAnalysis();
const totalAnalyses = await Analysis.getTotalAnalysesCount();
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const pendingUpdates = services.filter(s =>
!s.last_analyzed || new Date(s.last_analyzed) < thirtyDaysAgo
).length;
const html = await renderTemplate('admin/dashboard', {
title: 'Admin Dashboard',
description: 'Manage services and privacy analyses',
canonical: `${req.protocol}://${url.host}/admin/dashboard`,
services,
stats: {
totalServices: services.length,
totalAnalyses,
pendingUpdates
}
});
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}
// Admin add service form - GET /admin/services/new
if (method === 'GET' && pathname === '/admin/services/new') {
const sessionToken = req.cookies?.session_token;
if (!sessionToken) {
return new Response(null, {
status: 302,
headers: { Location: '/admin/login' }
});
}
const session = await AdminSession.findByToken(sessionToken);
if (!session) {
return new Response(null, {
status: 302,
headers: { Location: '/admin/login' }
});
}
const html = await renderTemplate('admin/service-form', {
title: 'Add New Service',
description: 'Add a new service to analyze',
canonical: `${req.protocol}://${url.host}/admin/services/new`,
service: null
});
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}
// Admin create service - POST /admin/services
if (method === 'POST' && pathname === '/admin/services') {
const sessionToken = req.cookies?.session_token;
if (!sessionToken || !(await AdminSession.findByToken(sessionToken))) {
return new Response(null, {
status: 302,
headers: { Location: '/admin/login' }
});
}
try {
console.log('Processing POST /admin/services');
console.log('Content-Type:', req.headers.get('content-type'));
const formData = await req.formData();
console.log('FormData received');
const data = {
name: formData.get('name'),
url: formData.get('url'),
policy_url: formData.get('policy_url'),
logo_url: formData.get('logo_url') || null
};
console.log('Extracted data:', {
name: data.name,
url: data.url,
policy_url: data.policy_url,
logo_url: data.logo_url
});
if (!data.name || !data.url || !data.policy_url) {
console.log('Validation failed - missing required fields');
return new Response('Name, URL, and policy URL are required', { status: 400 });
}
await Service.create(data);
console.log('Service created successfully');
return new Response(null, {
status: 302,
headers: { Location: '/admin/dashboard' }
});
} catch (error) {
console.error('Create service error:', error);
console.error('Error stack:', error.stack);
return new Response(`Failed to create service: ${error.message}`, { status: 500 });
}
}
// Admin edit service form - GET /admin/services/:id/edit
if (method === 'GET' && pathname.match(/^\/admin\/services\/\d+\/edit$/)) {
const sessionToken = req.cookies?.session_token;
if (!sessionToken || !(await AdminSession.findByToken(sessionToken))) {
return new Response(null, {
status: 302,
headers: { Location: '/admin/login' }
});
}
const match = pathname.match(/^\/admin\/services\/(\d+)\/edit$/);
const id = parseInt(match[1]);
const service = await Service.findById(id);
if (!service) {
return new Response('Service not found', { status: 404 });
}
const html = await renderTemplate('admin/service-form', {
title: 'Edit Service',
description: `Edit ${service.name}`,
canonical: `${req.protocol}://${url.host}/admin/services/${id}/edit`,
service
});
return new Response(html, {
headers: { 'Content-Type': 'text/html' }
});
}
// Admin update service - POST /admin/services/:id
if (method === 'POST' && pathname.match(/^\/admin\/services\/\d+$/)) {
const sessionToken = req.cookies?.session_token;
if (!sessionToken || !(await AdminSession.findByToken(sessionToken))) {
return new Response(null, {
status: 302,
headers: { Location: '/admin/login' }
});
}
try {
const match = pathname.match(/^\/admin\/services\/(\d+)$/);
const id = parseInt(match[1]);
console.log(`Processing POST /admin/services/${id}`);
const formData = await req.formData();
const data = {
name: formData.get('name'),
url: formData.get('url'),
policy_url: formData.get('policy_url'),
logo_url: formData.get('logo_url') || null
};
console.log('Update data:', {
name: data.name,
url: data.url,
policy_url: data.policy_url
});
await Service.update(id, data);
console.log('Service updated successfully');
return new Response(null, {
status: 302,
headers: { Location: '/admin/dashboard' }
});
} catch (error) {
console.error('Update service error:', error);
console.error('Error stack:', error.stack);
return new Response(`Failed to update service: ${error.message}`, { status: 500 });
}
}
// Admin delete service - POST /admin/services/:id/delete
if (method === 'POST' && pathname.match(/^\/admin\/services\/\d+\/delete$/)) {
const sessionToken = req.cookies?.session_token;
if (!sessionToken || !(await AdminSession.findByToken(sessionToken))) {
return new Response(null, {
status: 302,
headers: { Location: '/admin/login' }
});
}
try {
const match = pathname.match(/^\/admin\/services\/(\d+)\/delete$/);
const id = parseInt(match[1]);
await Service.delete(id);
return new Response(null, {
status: 302,
headers: { Location: '/admin/dashboard' }
});
} catch (error) {
return new Response('Failed to delete service', { status: 500 });
}
}
// Admin analyze service - POST /admin/services/:id/analyze
if (method === 'POST' && pathname.match(/^\/admin\/services\/\d+\/analyze$/)) {
const sessionToken = req.cookies?.session_token;
if (!sessionToken || !(await AdminSession.findByToken(sessionToken))) {
return new Response(null, {
status: 302,
headers: { Location: '/admin/login' }
});
}
try {
const match = pathname.match(/^\/admin\/services\/(\d+)\/analyze$/);
const id = parseInt(match[1]);
const service = await Service.findById(id);
if (!service) {
return new Response('Service not found', { status: 404 });
}
console.log(`Starting analysis for service: ${service.name}`);
// Fetch policy
const policyData = await PolicyFetcher.fetchPolicy(service.policy_url);
// Check if content has changed
const contentHash = PolicyVersion.generateContentHash(policyData.content);
const existingVersion = await PolicyVersion.findByContentHash(id, contentHash);
let policyVersion;
if (existingVersion) {
console.log('Policy content unchanged, using existing version');
policyVersion = existingVersion;
} else {
console.log('New policy content detected, creating new version');
policyVersion = await PolicyVersion.create({
service_id: id,
content: policyData.content,
fetched_at: policyData.fetchedAt
});
}
// Analyze with AI
const analysisResult = await AIAnalyzer.analyzePolicy(policyData.content);
// Save analysis
const analysis = await Analysis.create({
service_id: id,
policy_version_id: policyVersion.id,
overall_score: analysisResult.overall_score,
findings: analysisResult.findings,
raw_analysis: analysisResult.raw_response
});
console.log(`Analysis complete for ${service.name}: Grade ${analysis.overall_score}`);
return new Response(null, {
status: 302,
headers: { Location: '/admin/dashboard' }
});
} catch (error) {
console.error('Analysis error:', error);
return new Response(`Analysis failed: ${error.message}`, { status: 500 });
}
}
// Health check
if (method === 'GET' && pathname === '/api/health') {
return new Response(JSON.stringify({
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0'
}), {
headers: { 'Content-Type': 'application/json' }
});
}
// Static files - serve favicon
if (method === 'GET' && pathname === '/favicon.svg') {
try {
const faviconPath = join(__dirname, '../public/favicon.svg');
const favicon = await readFile(faviconPath);
return new Response(favicon, {
headers: { 'Content-Type': 'image/svg+xml' }
});
} catch {
return new Response('Not found', { status: 404 });
}
}
// 404 Not Found
return new Response('Not Found', { status: 404 });
} catch (error) {
console.error('Request handler error:', error);
return new Response('Internal Server Error', { status: 500 });
}
}
// Create Bun server
const server = Bun.serve({
port: process.env.PORT || 3000,
async fetch(req) {
try {
const response = await handleRequest(req);
// Add security headers
const headers = new Headers(response.headers);
headers.set('X-Content-Type-Options', 'nosniff');
headers.set('X-Frame-Options', 'DENY');
headers.set('X-XSS-Protection', '1; mode=block');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
} catch (error) {
console.error('Server error:', error);
return new Response('Internal Server Error', { status: 500 });
}
}
});
console.log(`Server running at http://localhost:${server.port}`);
// Initialize scheduler and search indexer (non-blocking)
(async () => {
try {
// Initialize scheduler
Scheduler.init();
// Initialize search indexer
await SearchIndexer.init();
console.log('Search indexer initialized');
} catch (error) {
console.error('Failed to initialize services:', error);
}
})();

15
src/config/database.js Normal file
View File

@@ -0,0 +1,15 @@
import postgres from 'postgres';
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is required');
}
export const sql = postgres(connectionString, {
max: 20, // Connection pool size
idle_timeout: 20,
connect_timeout: 10,
});
export default sql;

11
src/config/meilisearch.js Normal file
View File

@@ -0,0 +1,11 @@
import { MeiliSearch } from 'meilisearch';
const host = process.env.MEILISEARCH_URL || 'http://meilisearch:7700';
const apiKey = process.env.MEILISEARCH_API_KEY;
export const meilisearch = new MeiliSearch({
host,
apiKey,
});
export default meilisearch;

13
src/config/openai.js Normal file
View File

@@ -0,0 +1,13 @@
import OpenAI from 'openai';
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OPENAI_API_KEY environment variable is required');
}
export const openai = new OpenAI({
apiKey,
});
export default openai;

21
src/config/redis.js Normal file
View File

@@ -0,0 +1,21 @@
import Redis from 'ioredis';
const redisUrl = process.env.REDIS_URL || 'redis://redis:6379';
export const redis = new Redis(redisUrl, {
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
});
redis.on('error', (err) => {
console.error('Redis error:', err);
});
redis.on('connect', () => {
console.log('Redis connected');
});
export default redis;

84
src/middleware/auth.js Normal file
View File

@@ -0,0 +1,84 @@
import bcrypt from 'bcrypt';
import { AdminSession } from '../models/AdminSession.js';
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD_HASH = process.env.ADMIN_PASSWORD; // Should be bcrypt hash
export const authenticate = async (req, res, next) => {
try {
const sessionToken = req.cookies?.session_token;
if (!sessionToken) {
return res.redirect('/admin/login');
}
const session = await AdminSession.findByToken(sessionToken);
if (!session) {
res.clearCookie('session_token');
return res.redirect('/admin/login');
}
// Extend session on each request
await AdminSession.extendSession(sessionToken);
req.admin = { sessionToken };
next();
} catch (error) {
console.error('Auth middleware error:', error);
return res.redirect('/admin/login');
}
};
export const login = async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
// Check credentials
if (username !== ADMIN_USERNAME) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const isValidPassword = await bcrypt.compare(password, ADMIN_PASSWORD_HASH);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create session
const session = await AdminSession.create();
// Set cookie
res.cookie('session_token', session.session_token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
});
res.json({ success: true, redirect: '/admin/dashboard' });
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
};
export const logout = async (req, res) => {
try {
const sessionToken = req.cookies?.session_token;
if (sessionToken) {
await AdminSession.deleteByToken(sessionToken);
}
res.clearCookie('session_token');
res.redirect('/admin/login');
} catch (error) {
console.error('Logout error:', error);
res.redirect('/admin/login');
}
};

View File

@@ -0,0 +1,32 @@
export const errorHandler = (err, req, res, next) => {
console.error('Error:', err);
// Don't leak error details in production
const isDevelopment = process.env.NODE_ENV !== 'production';
if (err.name === 'PostgresError') {
return res.status(500).json({
error: 'Database error',
message: isDevelopment ? err.message : 'Internal server error'
});
}
if (err.name === 'OpenAIError') {
return res.status(503).json({
error: 'AI service unavailable',
message: isDevelopment ? err.message : 'Service temporarily unavailable'
});
}
res.status(err.status || 500).json({
error: 'Internal server error',
message: isDevelopment ? err.message : 'Something went wrong'
});
};
export const notFound = (req, res) => {
res.status(404).json({
error: 'Not found',
message: 'The requested resource was not found'
});
};

View File

@@ -0,0 +1,47 @@
import redis from '../config/redis.js';
const RATE_LIMITS = {
public: { requests: 100, window: 15 * 60 }, // 100 requests per 15 minutes
admin: { requests: 30, window: 15 * 60 }, // 30 requests per 15 minutes
ai: { requests: 10, window: 60 * 60 } // 10 requests per hour
};
export const rateLimiter = (type = 'public') => {
return async (req, res, next) => {
try {
const key = `rate_limit:${type}:${req.ip}`;
const { requests, window } = RATE_LIMITS[type];
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, window);
}
const ttl = await redis.ttl(key);
res.setHeader('X-RateLimit-Limit', requests);
res.setHeader('X-RateLimit-Remaining', Math.max(0, requests - current));
res.setHeader('X-RateLimit-Reset', new Date(Date.now() + ttl * 1000).toISOString());
if (current > requests) {
return res.status(429).json({
error: 'Too many requests',
message: `Rate limit exceeded. Try again in ${Math.ceil(ttl / 60)} minutes.`,
retryAfter: ttl
});
}
next();
} catch (error) {
console.error('Rate limiter error:', error);
// Fail open in case of Redis error
next();
}
};
};
// Specialized rate limiters
export const publicRateLimiter = rateLimiter('public');
export const adminRateLimiter = rateLimiter('admin');
export const aiRateLimiter = rateLimiter('ai');

View File

@@ -0,0 +1,41 @@
export const securityHeaders = (req, res, next) => {
// HSTS - Force HTTPS
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// Content Security Policy
res.setHeader('Content-Security-Policy',
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self';"
);
// Prevent MIME type sniffing
res.setHeader('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking
res.setHeader('X-Frame-Options', 'DENY');
// XSS Protection (legacy browsers)
res.setHeader('X-XSS-Protection', '1; mode=block');
// Referrer policy
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions policy
res.setHeader('Permissions-Policy',
'geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()'
);
// Remove powered by header
if (res.removeHeader) {
res.removeHeader('X-Powered-By');
}
next();
};

View File

@@ -0,0 +1,59 @@
import { sql } from '../config/database.js';
import crypto from 'crypto';
export class AdminSession {
static TOKEN_EXPIRY_HOURS = 24;
static generateToken() {
return crypto.randomBytes(32).toString('hex');
}
static async create() {
const token = this.generateToken();
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + this.TOKEN_EXPIRY_HOURS);
const [result] = await sql`
INSERT INTO admin_sessions (session_token, expires_at)
VALUES (${token}, ${expiresAt})
RETURNING *
`;
return result;
}
static async findByToken(token) {
const [result] = await sql`
SELECT * FROM admin_sessions
WHERE session_token = ${token}
AND expires_at > CURRENT_TIMESTAMP
`;
return result || null;
}
static async deleteByToken(token) {
await sql`DELETE FROM admin_sessions WHERE session_token = ${token}`;
return true;
}
static async deleteExpired() {
const result = await sql`
DELETE FROM admin_sessions
WHERE expires_at <= CURRENT_TIMESTAMP
RETURNING id
`;
return result.length;
}
static async extendSession(token) {
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + this.TOKEN_EXPIRY_HOURS);
const [result] = await sql`
UPDATE admin_sessions
SET expires_at = ${expiresAt}
WHERE session_token = ${token}
RETURNING *
`;
return result || null;
}
}

76
src/models/Analysis.js Normal file
View File

@@ -0,0 +1,76 @@
import { sql } from '../config/database.js';
export class Analysis {
static async findById(id) {
const [result] = await sql`SELECT * FROM analyses WHERE id = ${id}`;
return result || null;
}
static async findByServiceId(serviceId) {
const result = await sql`
SELECT * FROM analyses
WHERE service_id = ${serviceId}
ORDER BY created_at DESC
`;
return result;
}
static async findLatestByServiceId(serviceId) {
const [result] = await sql`
SELECT * FROM analyses
WHERE service_id = ${serviceId}
ORDER BY created_at DESC
LIMIT 1
`;
return result || null;
}
static async create(data) {
const [result] = await sql`
INSERT INTO analyses (service_id, policy_version_id, overall_score, findings, raw_analysis)
VALUES (
${data.service_id},
${data.policy_version_id},
${data.overall_score},
${JSON.stringify(data.findings)},
${data.raw_analysis}
)
RETURNING *
`;
return result;
}
static async findByGrade(grade) {
const result = await sql`
SELECT a.*, s.name as service_name, s.url as service_url
FROM analyses a
JOIN services s ON a.service_id = s.id
WHERE a.overall_score = ${grade}
AND a.created_at = (
SELECT MAX(created_at) FROM analyses
WHERE service_id = a.service_id
)
ORDER BY a.created_at DESC
`;
return result;
}
static async getServiceCountByGrade() {
const result = await sql`
SELECT overall_score, COUNT(*) as count
FROM analyses
WHERE created_at = (
SELECT MAX(created_at) FROM analyses a2
WHERE a2.service_id = analyses.service_id
)
GROUP BY overall_score
ORDER BY overall_score
`;
return result;
}
static async getTotalAnalysesCount() {
const [result] = await sql`SELECT COUNT(*) as count FROM analyses`;
return result.count;
}
}

View File

@@ -0,0 +1,53 @@
import { sql } from '../config/database.js';
import crypto from 'crypto';
export class PolicyVersion {
static async findById(id) {
const [result] = await sql`SELECT * FROM policy_versions WHERE id = ${id}`;
return result || null;
}
static async findByServiceId(serviceId) {
const result = await sql`
SELECT * FROM policy_versions
WHERE service_id = ${serviceId}
ORDER BY created_at DESC
`;
return result;
}
static async findLatestByServiceId(serviceId) {
const [result] = await sql`
SELECT * FROM policy_versions
WHERE service_id = ${serviceId}
ORDER BY created_at DESC
LIMIT 1
`;
return result || null;
}
static async create(data) {
const contentHash = crypto.createHash('sha256').update(data.content).digest('hex');
const [result] = await sql`
INSERT INTO policy_versions (service_id, content, content_hash, fetched_at)
VALUES (${data.service_id}, ${data.content}, ${contentHash}, ${data.fetched_at || new Date()})
RETURNING *
`;
return result;
}
static async findByContentHash(serviceId, contentHash) {
const [result] = await sql`
SELECT * FROM policy_versions
WHERE service_id = ${serviceId} AND content_hash = ${contentHash}
ORDER BY created_at DESC
LIMIT 1
`;
return result || null;
}
static generateContentHash(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
}

85
src/models/Service.js Normal file
View File

@@ -0,0 +1,85 @@
import { sql } from '../config/database.js';
export class Service {
static async findAll() {
const result = await sql`SELECT * FROM services ORDER BY created_at DESC`;
return result;
}
static async findById(id) {
const [result] = await sql`SELECT * FROM services WHERE id = ${id}`;
return result || null;
}
static async findByName(name) {
const [result] = await sql`SELECT * FROM services WHERE name ILIKE ${name}`;
return result || null;
}
static async create(data) {
const [result] = await sql`
INSERT INTO services (name, url, logo_url, policy_url)
VALUES (${data.name}, ${data.url}, ${data.logo_url}, ${data.policy_url})
RETURNING *
`;
return result;
}
static async update(id, data) {
const [result] = await sql`
UPDATE services
SET
name = ${data.name},
url = ${data.url},
logo_url = ${data.logo_url},
policy_url = ${data.policy_url},
updated_at = CURRENT_TIMESTAMP
WHERE id = ${id}
RETURNING *
`;
return result;
}
static async delete(id) {
await sql`DELETE FROM services WHERE id = ${id}`;
return true;
}
static async findWithLatestAnalysis(id) {
const [result] = await sql`
SELECT
s.*,
a.overall_score as grade,
a.created_at as last_analyzed,
a.findings
FROM services s
LEFT JOIN LATERAL (
SELECT * FROM analyses
WHERE service_id = s.id
ORDER BY created_at DESC
LIMIT 1
) a ON true
WHERE s.id = ${id}
`;
return result || null;
}
static async findAllWithLatestAnalysis() {
const result = await sql`
SELECT
s.*,
a.overall_score as grade,
a.created_at as last_analyzed,
a.findings
FROM services s
LEFT JOIN LATERAL (
SELECT * FROM analyses
WHERE service_id = s.id
ORDER BY created_at DESC
LIMIT 1
) a ON true
ORDER BY s.name ASC
`;
return result;
}
}

210
src/routes/admin.js Normal file
View File

@@ -0,0 +1,210 @@
import { authenticate, login, logout } from '../middleware/auth.js';
import { adminRateLimiter } from '../middleware/rateLimiter.js';
import { Service } from '../models/Service.js';
import { Analysis } from '../models/Analysis.js';
import { AdminSession } from '../models/AdminSession.js';
// Helper to render EJS templates
async function renderTemplate(template, data = {}) {
const ejs = await import('ejs');
const fs = await import('fs/promises');
const path = await import('path');
const { fileURLToPath } = await import('url');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const templatePath = path.join(__dirname, '../views', template + '.ejs');
const content = await fs.readFile(templatePath, 'utf-8');
// Simple layout system
if (data.layout !== false) {
const layoutPath = path.join(__dirname, '../views/layouts/main.ejs');
const layoutContent = await fs.readFile(layoutPath, 'utf-8');
// Create a contentFor function for the layout
data.contentFor = function(name) {
return data.body || '';
};
const body = ejs.render(content, data);
return ejs.render(layoutContent, { ...data, body });
}
return ejs.render(content, data);
}
export function setupAdminRoutes(app) {
// Login page (no auth required)
app.get('/admin/login', async (req, res) => {
try {
const html = await renderTemplate('admin/login', { layout: false });
res.setHeader('Content-Type', 'text/html');
res.status(200).send(html);
} catch (error) {
console.error('Login page error:', error);
res.status(500).send('Internal server error');
}
});
// Login POST
app.post('/admin/login', adminRateLimiter, async (req, res) => {
try {
const body = await req.json();
req.body = body;
await login(req, res);
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Logout
app.get('/admin/logout', authenticate, logout);
// Dashboard
app.get('/admin/dashboard', authenticate, async (req, res) => {
try {
// Clean up expired sessions
await AdminSession.deleteExpired();
const services = await Service.findAllWithLatestAnalysis();
const totalAnalyses = await Analysis.getTotalAnalysesCount();
// Count services with no analysis or old analysis (older than 30 days)
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const pendingUpdates = services.filter(s =>
!s.last_analyzed || new Date(s.last_analyzed) < thirtyDaysAgo
).length;
const html = await renderTemplate('admin/dashboard', {
title: 'Admin Dashboard',
description: 'Manage services and privacy analyses',
canonical: `${req.protocol}://${req.headers.host}/admin/dashboard`,
services,
stats: {
totalServices: services.length,
totalAnalyses,
pendingUpdates
}
});
res.setHeader('Content-Type', 'text/html');
res.status(200).send(html);
} catch (error) {
console.error('Dashboard error:', error);
res.status(500).send('Internal server error');
}
});
// Add service form
app.get('/admin/services/new', authenticate, async (req, res) => {
try {
const html = await renderTemplate('admin/service-form', {
title: 'Add New Service',
description: 'Add a new service to analyze',
canonical: `${req.protocol}://${req.headers.host}/admin/services/new`,
service: null
});
res.setHeader('Content-Type', 'text/html');
res.status(200).send(html);
} catch (error) {
console.error('Service form error:', error);
res.status(500).send('Internal server error');
}
});
// Create service
app.post('/admin/services', authenticate, async (req, res) => {
try {
const formData = await req.formData();
const data = {
name: formData.get('name'),
url: formData.get('url'),
policy_url: formData.get('policy_url'),
logo_url: formData.get('logo_url') || null
};
// Validate
if (!data.name || !data.url || !data.policy_url) {
return res.status(400).send('Name, URL, and policy URL are required');
}
await Service.create(data);
res.redirect('/admin/dashboard');
} catch (error) {
console.error('Create service error:', error);
res.status(500).send('Failed to create service');
}
});
// Edit service form
app.get('/admin/services/:id/edit', authenticate, async (req, res) => {
try {
const { id } = req.params;
const service = await Service.findById(id);
if (!service) {
return res.status(404).send('Service not found');
}
const html = await renderTemplate('admin/service-form', {
title: 'Edit Service',
description: `Edit ${service.name}`,
canonical: `${req.protocol}://${req.headers.host}/admin/services/${id}/edit`,
service
});
res.setHeader('Content-Type', 'text/html');
res.status(200).send(html);
} catch (error) {
console.error('Edit service error:', error);
res.status(500).send('Internal server error');
}
});
// Update service
app.post('/admin/services/:id', authenticate, async (req, res) => {
try {
const { id } = req.params;
const formData = await req.formData();
const data = {
name: formData.get('name'),
url: formData.get('url'),
policy_url: formData.get('policy_url'),
logo_url: formData.get('logo_url') || null
};
const service = await Service.findById(id);
if (!service) {
return res.status(404).send('Service not found');
}
await Service.update(id, data);
res.redirect('/admin/dashboard');
} catch (error) {
console.error('Update service error:', error);
res.status(500).send('Failed to update service');
}
});
// Delete service
app.post('/admin/services/:id/delete', authenticate, async (req, res) => {
try {
const { id } = req.params;
await Service.delete(id);
res.redirect('/admin/dashboard');
} catch (error) {
console.error('Delete service error:', error);
res.status(500).send('Failed to delete service');
}
});
// Trigger analysis (placeholder - will be implemented in Phase 4)
app.post('/admin/services/:id/analyze', authenticate, async (req, res) => {
// This will be implemented with the AI analyzer service
res.status(501).send('Analysis feature coming in Phase 4');
});
}

135
src/routes/public.js Normal file
View File

@@ -0,0 +1,135 @@
import { Service } from '../models/Service.js';
import { Analysis } from '../models/Analysis.js';
// Helper to render EJS templates
async function renderTemplate(template, data = {}) {
const ejs = await import('ejs');
const fs = await import('fs/promises');
const path = await import('path');
const { fileURLToPath } = await import('url');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const templatePath = path.join(__dirname, '../views', template + '.ejs');
const content = await fs.readFile(templatePath, 'utf-8');
// Simple layout system
if (data.layout !== false) {
const layoutPath = path.join(__dirname, '../views/layouts/main.ejs');
const layoutContent = await fs.readFile(layoutPath, 'utf-8');
const body = ejs.render(content, data);
return ejs.render(layoutContent, { ...data, body });
}
return ejs.render(content, data);
}
export function setupPublicRoutes(app) {
// Homepage
app.get('/', async (req, res) => {
try {
const grade = req.query.grade;
const page = parseInt(req.query.page) || 1;
const limit = 25;
let services;
if (grade) {
// Filter by grade
services = await Analysis.findByGrade(grade);
} else {
// Get all services with their latest analysis
services = await Service.findAllWithLatestAnalysis();
}
// Sort by grade (A first, E last) then by name
services.sort((a, b) => {
if (a.grade && b.grade) {
return a.grade.localeCompare(b.grade) || a.name.localeCompare(b.name);
}
if (!a.grade) return 1;
if (!b.grade) return 1;
return a.name.localeCompare(b.name);
});
// Pagination
const total = services.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedServices = services.slice(startIndex, endIndex);
const html = await renderTemplate('public/index', {
title: 'Privacy Policy Analyzer | Compare Website Privacy Practices',
description: 'We analyze privacy policies and give clear A-E grades. See how your favorite services handle your data.',
canonical: `${req.protocol}://${req.headers.host}/`,
services: paginatedServices,
pagination: {
currentPage: page,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
});
res.setHeader('Content-Type', 'text/html');
res.status(200).send(html);
} catch (error) {
console.error('Homepage error:', error);
res.status(500).send('Internal server error');
}
});
// Service detail page
app.get('/service/:id', async (req, res) => {
try {
const { id } = req.params;
const service = await Service.findById(id);
if (!service) {
return res.status(404).send('Service not found');
}
const analysis = await Analysis.findLatestByServiceId(id);
const html = await renderTemplate('public/service', {
title: `${service.name} Privacy Policy Analysis | ${analysis ? 'Grade ' + analysis.overall_score : 'Not Analyzed'}`,
description: `See the privacy grade for ${service.name}. We analyzed their privacy policy to show you how they handle your data.`,
canonical: `${req.protocol}://${req.headers.host}/service/${id}`,
service,
analysis
});
res.setHeader('Content-Type', 'text/html');
res.status(200).send(html);
} catch (error) {
console.error('Service detail error:', error);
res.status(500).send('Internal server error');
}
});
// Search (placeholder - will be implemented with Meilisearch)
app.get('/search', async (req, res) => {
try {
const query = req.query.q;
// For now, just show all services
// In Phase 4, this will use Meilisearch
const services = await Service.findAllWithLatestAnalysis();
const html = await renderTemplate('public/index', {
title: query ? `Search Results for "${query}" | Privacy Policy Analyzer` : 'Search Services',
description: 'Search for services and see their privacy grades.',
canonical: `${req.protocol}://${req.headers.host}/search`,
services
});
res.setHeader('Content-Type', 'text/html');
res.status(200).send(html);
} catch (error) {
console.error('Search error:', error);
res.status(500).send('Internal server error');
}
});
}

View File

@@ -0,0 +1,16 @@
import bcrypt from 'bcrypt';
async function hashPassword() {
const password = process.argv[2];
if (!password) {
console.error('Usage: bun run src/scripts/hash-password.js <password>');
process.exit(1);
}
const hash = await bcrypt.hash(password, 12);
console.log('Password hash:');
console.log(hash);
}
hashPassword();

View File

@@ -0,0 +1,50 @@
// Health check script for Docker
import { sql } from '../config/database.js';
import Redis from 'ioredis';
import { MeiliSearch } from 'meilisearch';
const checks = {
database: false,
redis: false,
meilisearch: false
};
try {
// Check database
await sql`SELECT 1`;
checks.database = true;
} catch (error) {
console.error('Database check failed:', error.message);
}
try {
// Check Redis
const redis = new Redis(process.env.REDIS_URL);
await redis.ping();
checks.redis = true;
redis.disconnect();
} catch (error) {
console.error('Redis check failed:', error.message);
}
try {
// Check Meilisearch
const client = new MeiliSearch({
host: process.env.MEILISEARCH_URL,
apiKey: process.env.MEILISEARCH_API_KEY
});
await client.health();
checks.meilisearch = true;
} catch (error) {
console.error('Meilisearch check failed:', error.message);
}
const allHealthy = Object.values(checks).every(Boolean);
if (allHealthy) {
console.log('All services healthy');
process.exit(0);
} else {
console.log('Some services unhealthy:', checks);
process.exit(1);
}

55
src/scripts/migrate.js Normal file
View File

@@ -0,0 +1,55 @@
// Database migration runner
import postgres from 'postgres';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const sql = postgres(process.env.DATABASE_URL);
async function runMigrations() {
console.log('Running database migrations...');
try {
// Create migrations table if it doesn't exist
await sql`
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`;
// Read migration file
const migrationPath = join(__dirname, '../../migrations/001_initial.sql');
const migration = readFileSync(migrationPath, 'utf-8');
// Check if already applied
const [existing] = await sql`
SELECT * FROM migrations WHERE name = '001_initial.sql'
`;
if (existing) {
console.log('Migration 001_initial.sql already applied');
return;
}
// Run migration
await sql.unsafe(migration);
// Record migration
await sql`
INSERT INTO migrations (name) VALUES ('001_initial.sql')
`;
console.log('Migration 001_initial.sql applied successfully');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await sql.end();
}
}
runMigrations();

61
src/scripts/test-ai.js Normal file
View File

@@ -0,0 +1,61 @@
import { AIAnalyzer } from '../services/aiAnalyzer.js';
async function testAI() {
console.log('Testing OpenAI integration...');
const testPolicy = `Privacy Policy for TestService
Last updated: January 1, 2024
Information We Collect:
- Name and email address when you create an account
- Usage data including pages visited and features used
- Device information such as browser type and IP address
How We Use Your Information:
- To provide and improve our services
- To communicate with you about updates and features
- For analytics and service optimization
Data Sharing:
We share data with the following third parties:
- Google Analytics for usage analytics
- Stripe for payment processing (if applicable)
- AWS for cloud hosting services
Your Rights:
- You can access and update your personal information
- You can request deletion of your account and data
- You can opt out of marketing communications
Cookies:
We use cookies for session management, analytics, and personalization.
Data Retention:
We retain your data for as long as your account is active, or as required by law.
Contact:
For privacy questions, contact privacy@testservice.com`;
try {
console.log('Sending test policy to OpenAI...');
const result = await AIAnalyzer.analyzePolicy(testPolicy);
console.log('\n✓ OpenAI integration working!');
console.log('\nAnalysis Results:');
console.log('Overall Score:', result.overall_score);
console.log('Summary:', result.summary);
console.log('\nScore Breakdown:');
console.log(JSON.stringify(result.score_breakdown, null, 2));
console.log('\nFindings:');
console.log('Positive:', result.findings.positive.length);
console.log('Negative:', result.findings.negative.length);
console.log('Neutral:', result.findings.neutral.length);
} catch (error) {
console.error('✗ Test failed:', error.message);
process.exit(1);
}
}
testAI();

View File

@@ -0,0 +1,37 @@
// Test script to verify models work correctly
import { Service } from '../models/Service.js';
import { PolicyVersion } from '../models/PolicyVersion.js';
import { Analysis } from '../models/Analysis.js';
import { AdminSession } from '../models/AdminSession.js';
async function testModels() {
console.log('Testing models...');
try {
// Test Service model
const services = await Service.findAll();
console.log('✓ Service.findAll() works - found', services.length, 'services');
// Test Analysis model
const analyses = await Analysis.findByServiceId(1);
console.log('✓ Analysis.findByServiceId() works - found', analyses.length, 'analyses');
// Test AdminSession
const session = await AdminSession.create();
console.log('✓ AdminSession.create() works');
const found = await AdminSession.findByToken(session.session_token);
console.log('✓ AdminSession.findByToken() works');
await AdminSession.deleteByToken(session.session_token);
console.log('✓ AdminSession.deleteByToken() works');
console.log('\n✓ All models working correctly!');
process.exit(0);
} catch (error) {
console.error('✗ Model test failed:', error.message);
process.exit(1);
}
}
testModels();

View File

@@ -0,0 +1,68 @@
import { PolicyFetcher } from '../services/policyFetcher.js';
// Test HTML extraction
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<title>Privacy Policy</title>
<style>
body { font-family: Arial; }
.nav { display: none; }
</style>
<script>
console.log('tracking code');
function track() { return true; }
</script>
</head>
<body>
<nav class="nav">
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<header>
<h1>Privacy Policy</h1>
</header>
<main>
<h2>Information We Collect</h2>
<p>We collect your name, email address, and usage data to provide our services.</p>
<h2>How We Use Your Information</h2>
<p>We use this information to:</p>
<ul>
<li>Provide and improve our services</li>
<li>Communicate with you</li>
<li>Ensure security</li>
</ul>
<h2>Your Rights</h2>
<p>You have the right to access, delete, and port your data.</p>
</main>
<footer>
<p>&copy; 2024 Company Name. All rights reserved.</p>
<a href="/contact">Contact Us</a>
</footer>
<button onclick="track()">Accept</button>
<form>
<input type="email" placeholder="Subscribe">
</form>
</body>
</html>
`;
console.log('Original HTML length:', htmlContent.length);
console.log('\nOriginal content (first 200 chars):');
console.log(htmlContent.substring(0, 200));
const cleaned = PolicyFetcher.cleanContent(htmlContent);
console.log('\n\nCleaned text length:', cleaned.length);
console.log('\nCleaned content:');
console.log(cleaned);
console.log('\n\nToken reduction:', Math.round((1 - cleaned.length / htmlContent.length) * 100) + '%');

254
src/services/aiAnalyzer.js Normal file
View File

@@ -0,0 +1,254 @@
/**
* Service to analyze privacy policies using OpenAI
*/
import openai from '../config/openai.js';
export class AIAnalyzer {
static MAX_RETRIES = 3;
static RETRY_DELAY_MS = 2000;
/**
* System prompt for the AI
*/
static SYSTEM_PROMPT = `You are a privacy policy analyzer. Your task is to analyze privacy policies and provide structured assessments.
Scoring Criteria (A-E grades):
- Grade A: Excellent privacy practices. Minimal data collection, strong user rights, transparent policies, no third-party sharing without consent.
- Grade B: Good privacy practices. Some data collection but with clear purposes, users have control over their data, reasonable retention periods.
- Grade C: Acceptable but concerning. Moderate data collection, some third-party sharing, limited user control, standard industry practices.
- Grade D: Poor privacy practices. Extensive data collection, significant third-party sharing, limited user rights, vague policies.
- Grade E: Very invasive. Excessive data collection, broad third-party sharing including sale of data, minimal user rights, tracking across services.
Analysis Categories:
1. Data Collection - What personal data is collected and how
2. Data Sharing - Third parties data is shared with and purposes
3. User Rights - What control users have over their data
4. Data Retention - How long data is kept
5. Tracking & Security - Tracking technologies and security measures
Respond ONLY with valid JSON matching this exact schema:
{
"overall_score": "A|B|C|D|E",
"score_breakdown": {
"data_collection": "A|B|C|D|E",
"data_sharing": "A|B|C|D|E",
"user_rights": "A|B|C|D|E",
"data_retention": "A|B|C|D|E",
"tracking_security": "A|B|C|D|E"
},
"findings": {
"positive": [
{
"category": "data_collection|data_sharing|user_rights|data_retention|tracking_security",
"title": "Brief title",
"description": "Detailed description of the positive aspect",
"severity": "good"
}
],
"negative": [
{
"category": "data_collection|data_sharing|user_rights|data_retention|tracking_security",
"title": "Brief title",
"description": "Detailed description of the concern",
"severity": "blocker|bad"
}
],
"neutral": [
{
"category": "general",
"title": "Brief title",
"description": "Description of neutral information",
"severity": "neutral"
}
]
},
"data_types_collected": ["list", "of", "data", "types"],
"third_parties": [
{
"name": "Company Name",
"purpose": "Purpose of sharing"
}
],
"summary": "2-3 sentence summary of the overall privacy assessment"
}
Important:
- Be thorough but concise in descriptions (50-150 words each)
- Mark critical issues as "blocker", significant issues as "bad"
- Include at least 2-3 positive aspects if they exist
- Include all significant negative aspects
- Be objective and factual based on the policy text provided`;
/**
* Analyze a privacy policy
* @param {string} policyText - The privacy policy text to analyze
* @returns {Promise<Object>} - Analysis results
*/
static async analyzePolicy(policyText) {
// Token-efficient truncation - reduce to ~8000 chars (~2000 tokens) for cost savings
const maxLength = 8000;
let truncatedText = policyText;
if (policyText.length > maxLength) {
console.warn(`Policy text too long (${policyText.length} chars), truncating to ${maxLength} to save tokens`);
// Keep the beginning (usually contains important info) and end (usually has rights/legal)
const beginning = policyText.substring(0, Math.floor(maxLength * 0.7));
const end = policyText.substring(policyText.length - Math.floor(maxLength * 0.25));
truncatedText = beginning + '\n\n[... Content truncated to save tokens ...]\n\n' + end;
}
let lastError;
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
try {
console.log(`Analyzing policy with OpenAI (attempt ${attempt}/${this.MAX_RETRIES})`);
const response = await openai.chat.completions.create({
model: process.env.OPENAI_MODEL || 'gpt-4o',
messages: [
{
role: 'system',
content: this.SYSTEM_PROMPT
},
{
role: 'user',
content: `Analyze this privacy policy and provide a structured assessment:\n\n${truncatedText}`
}
],
response_format: { type: 'json_object' },
temperature: 0.3, // Lower temperature for more consistent results
max_tokens: 4000
});
const content = response.choices[0].message.content;
// Parse the JSON response
let analysis;
try {
analysis = JSON.parse(content);
} catch (parseError) {
console.error('Failed to parse AI response as JSON:', parseError);
throw new Error('Invalid JSON response from AI');
}
// Validate response structure
this.validateAnalysis(analysis);
console.log(`Analysis complete. Overall score: ${analysis.overall_score}`);
return {
...analysis,
raw_response: content,
model: response.model,
usage: response.usage
};
} catch (error) {
lastError = error;
console.error(`Analysis attempt ${attempt} failed:`, error.message);
// Check for specific error types
if (error.status === 429 || error.message?.includes('429')) {
const errorMsg = 'OpenAI API rate limit exceeded. Please check your plan and billing details at https://platform.openai.com/account/billing';
console.error(errorMsg);
// Don't retry on rate limit - it won't help
throw new Error(errorMsg);
}
if (error.status === 401 || error.message?.includes('401')) {
const errorMsg = 'OpenAI API authentication failed. Please check your API key.';
console.error(errorMsg);
throw new Error(errorMsg);
}
if (attempt < this.MAX_RETRIES) {
const delay = this.RETRY_DELAY_MS * attempt;
console.log(`Waiting ${delay}ms before retry...`);
await this.sleep(delay);
}
}
}
throw new Error(`Analysis failed after ${this.MAX_RETRIES} attempts: ${lastError.message}`);
}
/**
* Validate analysis structure
* @param {Object} analysis - The analysis object
* @throws {Error} - If structure is invalid
*/
static validateAnalysis(analysis) {
const validGrades = ['A', 'B', 'C', 'D', 'E'];
const requiredFields = ['overall_score', 'score_breakdown', 'findings', 'summary'];
// Check required fields
for (const field of requiredFields) {
if (!(field in analysis)) {
throw new Error(`Missing required field: ${field}`);
}
}
// Validate overall_score
if (!validGrades.includes(analysis.overall_score)) {
throw new Error(`Invalid overall_score: ${analysis.overall_score}`);
}
// Validate score_breakdown
const breakdownFields = ['data_collection', 'data_sharing', 'user_rights', 'data_retention', 'tracking_security'];
for (const field of breakdownFields) {
if (!(field in analysis.score_breakdown)) {
throw new Error(`Missing score_breakdown field: ${field}`);
}
if (!validGrades.includes(analysis.score_breakdown[field])) {
throw new Error(`Invalid score for ${field}: ${analysis.score_breakdown[field]}`);
}
}
// Validate findings
if (!Array.isArray(analysis.findings.positive)) {
analysis.findings.positive = [];
}
if (!Array.isArray(analysis.findings.negative)) {
analysis.findings.negative = [];
}
if (!Array.isArray(analysis.findings.neutral)) {
analysis.findings.neutral = [];
}
// Ensure arrays
if (!Array.isArray(analysis.data_types_collected)) {
analysis.data_types_collected = [];
}
if (!Array.isArray(analysis.third_parties)) {
analysis.third_parties = [];
}
}
/**
* Calculate overall score from breakdown
* @param {Object} breakdown - Score breakdown
* @returns {string} - Overall grade
*/
static calculateOverallScore(breakdown) {
const gradeValues = { A: 5, B: 4, C: 3, D: 2, E: 1 };
const grades = Object.values(breakdown);
const total = grades.reduce((sum, grade) => sum + (gradeValues[grade] || 0), 0);
const average = total / grades.length;
if (average >= 4.5) return 'A';
if (average >= 3.5) return 'B';
if (average >= 2.5) return 'C';
if (average >= 1.5) return 'D';
return 'E';
}
/**
* Sleep helper
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
static sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,196 @@
/**
* Service to fetch privacy policies from URLs
* Handles timeouts, retries, and content validation
*/
export class PolicyFetcher {
static TIMEOUT_MS = 30000; // 30 second timeout
static MAX_RETRIES = 3;
static RETRY_DELAY_MS = 1000;
/**
* Fetch policy content from URL
* @param {string} url - The URL to fetch
* @returns {Promise<{content: string, fetchedAt: Date}>}
*/
static async fetchPolicy(url) {
let lastError;
for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) {
try {
console.log(`Fetching policy from ${url} (attempt ${attempt}/${this.MAX_RETRIES})`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT_MS);
const response = await fetch(url, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 PrivacyPolicyAnalyzer/1.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'DNT': '1',
'Connection': 'keep-alive',
},
signal: controller.signal,
redirect: 'follow',
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
// Check content type
if (contentType && !contentType.includes('text/html') && !contentType.includes('text/plain')) {
console.warn(`Unexpected content type: ${contentType}`);
}
// Get content
let content = await response.text();
// Basic validation
if (!content || content.trim().length === 0) {
throw new Error('Empty response received');
}
if (content.length < 100) {
throw new Error('Response too short to be a valid privacy policy');
}
// Clean up HTML if needed
content = this.cleanContent(content);
console.log(`Successfully fetched policy (${content.length} characters)`);
return {
content,
fetchedAt: new Date(),
url: response.url // Final URL after redirects
};
} catch (error) {
lastError = error;
console.error(`Fetch attempt ${attempt} failed:`, error.message);
if (attempt < this.MAX_RETRIES) {
console.log(`Waiting ${this.RETRY_DELAY_MS}ms before retry...`);
await this.sleep(this.RETRY_DELAY_MS * attempt);
}
}
}
throw new Error(`Failed to fetch policy after ${this.MAX_RETRIES} attempts: ${lastError.message}`);
}
/**
* Clean up fetched content - extracts text from HTML
* @param {string} content - Raw HTML content
* @returns {string} - Cleaned text content
*/
static cleanContent(content) {
// Remove script tags and their content
content = content.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ');
// Remove style tags and their content
content = content.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ');
// Remove nav, footer, header, aside elements (navigation and UI elements)
content = content.replace(/<(nav|footer|header|aside|menu|button|form|input|select|textarea)[^>]*>[\s\S]*?<\/\1>/gi, ' ');
// Remove common non-content elements
content = content.replace(/<(svg|canvas|img|video|audio|iframe|embed|object)[^>]*>[\s\S]*?<\/[^>]*>/gi, ' ');
// Remove all HTML tags and convert to text
// This keeps the text content but removes all markup
content = content.replace(/<[^>]+>/g, ' ');
// Convert HTML entities
const entities = {
'&nbsp;': ' ',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&apos;': "'",
'&ndash;': '-',
'&mdash;': '-',
'&ldquo;': '"',
'&rdquo;': '"',
'&lsquo;': "'",
'&rsquo;': "'",
'&hellip;': '...',
'&bull;': '•',
'&copy;': '©',
'&reg;': '®',
'&trade;': '™'
};
for (const [entity, char] of Object.entries(entities)) {
content = content.replace(new RegExp(entity, 'g'), char);
}
// Remove numeric entities (e.g., &#123;)
content = content.replace(/&#\d+;/g, '');
// Remove hex entities (e.g., &#x1F;)
content = content.replace(/&#x[0-9a-f]+;/gi, '');
// Clean up whitespace
// Replace multiple spaces with single space
content = content.replace(/\s+/g, ' ');
// Remove spaces at start/end of lines
content = content.replace(/^\s+|\s+$/gm, '');
// Replace multiple newlines with max 2
content = content.replace(/\n{3,}/g, '\n\n');
// Trim overall
content = content.trim();
return content;
}
/**
* Validate if content looks like a privacy policy
* @param {string} content - Content to validate
* @returns {boolean}
*/
static validatePolicyContent(content) {
const policyKeywords = [
'privacy',
'personal data',
'information we collect',
'how we use',
'cookies',
'third parties',
'your rights',
'data protection',
'gdpr',
'ccpa'
];
const contentLower = content.toLowerCase();
const keywordMatches = policyKeywords.filter(keyword =>
contentLower.includes(keyword)
);
// Should have at least 3 privacy-related keywords
return keywordMatches.length >= 3;
}
/**
* Sleep helper
* @param {number} ms - Milliseconds to sleep
* @returns {Promise<void>}
*/
static sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

214
src/services/scheduler.js Normal file
View File

@@ -0,0 +1,214 @@
/**
* Scheduler service for automated tasks
* Uses node-cron for scheduling
*/
import cron from 'node-cron';
import { Service } from '../models/Service.js';
import { PolicyVersion } from '../models/PolicyVersion.js';
import { Analysis } from '../models/Analysis.js';
import { AdminSession } from '../models/AdminSession.js';
import { PolicyFetcher } from './policyFetcher.js';
import { AIAnalyzer } from './aiAnalyzer.js';
export class Scheduler {
static tasks = [];
/**
* Initialize all scheduled tasks
*/
static init() {
console.log('Initializing scheduler...');
// Clean up expired sessions daily at 3 AM
this.schedule('0 3 * * *', async () => {
console.log('Running session cleanup...');
try {
const deleted = await AdminSession.deleteExpired();
console.log(`Cleaned up ${deleted} expired sessions`);
} catch (error) {
console.error('Session cleanup error:', error);
}
}, 'session-cleanup');
// Check for policy updates daily at 2 AM
this.schedule('0 2 * * *', async () => {
console.log('Checking for policy updates...');
try {
await this.checkPolicyUpdates();
} catch (error) {
console.error('Policy update check error:', error);
}
}, 'policy-update-check');
// Re-analyze stale policies weekly on Sunday at 1 AM
this.schedule('0 1 * * 0', async () => {
console.log('Running weekly re-analysis...');
try {
await this.reanalyzeStalePolicies();
} catch (error) {
console.error('Weekly re-analysis error:', error);
}
}, 'weekly-reanalysis');
console.log('Scheduler initialized with tasks:', this.tasks.map(t => t.name));
}
/**
* Schedule a task
* @param {string} cronExpression - Cron expression
* @param {Function} task - Task function
* @param {string} name - Task name
*/
static schedule(cronExpression, task, name) {
const scheduledTask = cron.schedule(cronExpression, task, {
scheduled: true,
timezone: 'UTC'
});
this.tasks.push({
name,
expression: cronExpression,
task: scheduledTask
});
}
/**
* Check all services for policy updates
*/
static async checkPolicyUpdates() {
const services = await Service.findAll();
const updatedServices = [];
const failedServices = [];
for (const service of services) {
try {
console.log(`Checking ${service.name}...`);
// Fetch current policy
const policyData = await PolicyFetcher.fetchPolicy(service.policy_url);
const contentHash = PolicyVersion.generateContentHash(policyData.content);
// Check if changed
const existingVersion = await PolicyVersion.findByContentHash(service.id, contentHash);
if (!existingVersion) {
console.log(`Policy changed for ${service.name}`);
// Create new version
await PolicyVersion.create({
service_id: service.id,
content: policyData.content,
fetched_at: policyData.fetchedAt
});
updatedServices.push(service);
}
} catch (error) {
console.error(`Failed to check ${service.name}:`, error.message);
failedServices.push({ service, error: error.message });
}
// Add delay between requests to be respectful
await this.sleep(2000);
}
console.log(`Policy update check complete. ${updatedServices.length} updates found, ${failedServices.length} failed`);
return { updatedServices, failedServices };
}
/**
* Re-analyze policies that haven't been analyzed in 30+ days
*/
static async reanalyzeStalePolicies() {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const services = await Service.findAllWithLatestAnalysis();
const staleServices = services.filter(s =>
!s.last_analyzed || new Date(s.last_analyzed) < thirtyDaysAgo
);
console.log(`Found ${staleServices.length} services needing re-analysis`);
for (const service of staleServices) {
try {
console.log(`Re-analyzing ${service.name}...`);
// Fetch latest policy
const policyData = await PolicyFetcher.fetchPolicy(service.policy_url);
// Create new version
const policyVersion = await PolicyVersion.create({
service_id: service.id,
content: policyData.content,
fetched_at: policyData.fetchedAt
});
// Analyze
const analysisResult = await AIAnalyzer.analyzePolicy(policyData.content);
// Save
await Analysis.create({
service_id: service.id,
policy_version_id: policyVersion.id,
overall_score: analysisResult.overall_score,
findings: analysisResult.findings,
raw_analysis: analysisResult.raw_response
});
console.log(`Re-analysis complete for ${service.name}: Grade ${analysisResult.overall_score}`);
} catch (error) {
console.error(`Failed to re-analyze ${service.name}:`, error.message);
}
// Delay between analyses
await this.sleep(5000);
}
console.log('Weekly re-analysis complete');
}
/**
* Run a task immediately (for manual triggering)
* @param {string} taskName - Name of the task to run
*/
static async runTask(taskName) {
const task = this.tasks.find(t => t.name === taskName);
if (task) {
console.log(`Manually running task: ${taskName}`);
await task.task._callback();
} else {
throw new Error(`Task not found: ${taskName}`);
}
}
/**
* Stop all scheduled tasks
*/
static stop() {
this.tasks.forEach(({ task }) => task.stop());
this.tasks = [];
console.log('All scheduled tasks stopped');
}
/**
* Get status of all tasks
*/
static getStatus() {
return this.tasks.map(({ name, expression }) => ({
name,
expression,
running: true
}));
}
/**
* Sleep helper
* @param {number} ms - Milliseconds
*/
static sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,188 @@
/**
* Meilisearch indexer for search functionality
*/
import meilisearch from '../config/meilisearch.js';
import { Service } from '../models/Service.js';
import { Analysis } from '../models/Analysis.js';
const INDEX_NAME = 'services';
export class SearchIndexer {
static index = null;
/**
* Initialize the search index
*/
static async init() {
try {
console.log('Initializing Meilisearch...');
// Check if index exists
const indexes = await meilisearch.getIndexes();
const indexExists = indexes.results.some(idx => idx.uid === INDEX_NAME);
if (!indexExists) {
console.log('Creating search index...');
await meilisearch.createIndex(INDEX_NAME, { primaryKey: 'id' });
}
this.index = meilisearch.index(INDEX_NAME);
// Configure searchable attributes
await this.index.updateSettings({
searchableAttributes: [
'name',
'findings.positive.title',
'findings.positive.description',
'findings.negative.title',
'findings.negative.description',
'data_types_collected',
'third_parties.name'
],
filterableAttributes: ['grade', 'overall_score'],
sortableAttributes: ['name', 'created_at', 'overall_score'],
rankingRules: [
'words',
'typo',
'proximity',
'attribute',
'sort',
'exactness'
]
});
console.log('Meilisearch initialized successfully');
} catch (error) {
console.error('Meilisearch initialization error:', error);
throw error;
}
}
/**
* Index a single service
* @param {Object} service - Service with analysis
*/
static async indexService(service) {
try {
if (!this.index) {
await this.init();
}
const document = {
id: service.id,
name: service.name,
url: service.url,
logo_url: service.logo_url,
grade: service.grade,
overall_score: service.overall_score,
findings: service.findings || { positive: [], negative: [], neutral: [] },
data_types_collected: service.data_types_collected || [],
third_parties: service.third_parties || [],
last_analyzed: service.last_analyzed,
created_at: service.created_at
};
await this.index.addDocuments([document]);
console.log(`Indexed service: ${service.name}`);
} catch (error) {
console.error(`Failed to index service ${service.name}:`, error);
throw error;
}
}
/**
* Index all services
*/
static async indexAll() {
try {
console.log('Indexing all services...');
const services = await Service.findAllWithLatestAnalysis();
const documents = services.map(service => ({
id: service.id,
name: service.name,
url: service.url,
logo_url: service.logo_url,
grade: service.grade,
overall_score: service.overall_score,
findings: service.findings || { positive: [], negative: [], neutral: [] },
data_types_collected: service.data_types_collected || [],
third_parties: service.third_parties || [],
last_analyzed: service.last_analyzed,
created_at: service.created_at
}));
if (documents.length > 0) {
await this.index.addDocuments(documents);
console.log(`Indexed ${documents.length} services`);
}
return { indexed: documents.length };
} catch (error) {
console.error('Bulk indexing error:', error);
throw error;
}
}
/**
* Search services
* @param {string} query - Search query
* @param {Object} options - Search options
*/
static async search(query, options = {}) {
try {
if (!this.index) {
await this.init();
}
const searchOptions = {
limit: options.limit || 25,
offset: options.offset || 0,
...options
};
const results = await this.index.search(query, searchOptions);
return results;
} catch (error) {
console.error('Search error:', error);
throw error;
}
}
/**
* Delete a service from the index
* @param {number} serviceId - Service ID
*/
static async deleteService(serviceId) {
try {
if (!this.index) {
await this.init();
}
await this.index.deleteDocument(serviceId);
console.log(`Deleted service ${serviceId} from index`);
} catch (error) {
console.error(`Failed to delete service ${serviceId}:`, error);
throw error;
}
}
/**
* Get index stats
*/
static async getStats() {
try {
if (!this.index) {
await this.init();
}
const stats = await this.index.getStats();
return stats;
} catch (error) {
console.error('Failed to get stats:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,162 @@
<div class="admin-dashboard">
<h1>Admin Dashboard</h1>
<div class="stats-grid">
<div class="stat-card">
<h3>Total Services</h3>
<p class="stat-number"><%= stats.totalServices %></p>
</div>
<div class="stat-card">
<h3>Analyses Completed</h3>
<p class="stat-number"><%= stats.totalAnalyses %></p>
</div>
<div class="stat-card">
<h3>Pending Updates</h3>
<p class="stat-number"><%= stats.pendingUpdates %></p>
</div>
</div>
<div class="admin-section">
<div class="section-header">
<h2>Services</h2>
<a href="/admin/services/new" class="btn">Add New Service</a>
</div>
<% if (services && services.length > 0) { %>
<div class="service-list">
<% services.forEach(function(service) { %>
<div class="service-item">
<div class="service-info">
<h3><%= service.name %></h3>
<p class="service-url"><%= service.url %></p>
<% if (service.grade) { %>
<span class="grade grade-<%= service.grade.toLowerCase() %>">
Grade <%= service.grade %>
</span>
<p class="last-analyzed">
Last analyzed: <%= new Date(service.last_analyzed).toLocaleDateString() %>
</p>
<% } else { %>
<span class="no-grade">Not analyzed yet</span>
<% } %>
</div>
<div class="service-actions">
<a href="/admin/services/<%= service.id %>/edit" class="btn btn-small">Edit</a>
<form action="/admin/services/<%= service.id %>/analyze" method="POST" style="display: inline;">
<button type="submit" class="btn btn-success btn-small">Analyze</button>
</form>
<form action="/admin/services/<%= service.id %>/delete" method="POST" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this service?');">
<button type="submit" class="btn btn-danger btn-small">Delete</button>
</form>
</div>
</div>
<% }); %>
</div>
<% } else { %>
<div class="empty-state">
<p>No services added yet. <a href="/admin/services/new">Add your first service</a>.</p>
</div>
<% } %>
</div>
</div>
<style>
.admin-dashboard h1 {
margin-bottom: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: #fff;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}
.stat-card h3 {
font-size: 0.875rem;
color: #666;
margin-bottom: 0.5rem;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: #2c3e50;
}
.admin-section {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.service-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.service-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #eee;
border-radius: 4px;
}
.service-info h3 {
margin-bottom: 0.25rem;
}
.service-url {
color: #666;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.last-analyzed {
color: #999;
font-size: 0.75rem;
margin-top: 0.25rem;
}
.no-grade {
color: #999;
font-size: 0.875rem;
}
.service-actions {
display: flex;
gap: 0.5rem;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
</style>

194
src/views/admin/login.ejs Normal file
View File

@@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Privacy Policy Analyzer</title>
<meta name="description" content="Admin login for Privacy Policy Analyzer">
<meta name="robots" content="noindex, nofollow">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 1rem;
}
.login-container {
background: #fff;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
h1 {
text-align: center;
margin-bottom: 1.5rem;
color: #2c3e50;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
input:focus {
border-color: #3498db;
outline: 2px solid #3498db;
outline-offset: 2px;
}
button {
width: 100%;
padding: 0.75rem;
background: #3498db;
color: #fff;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
min-height: 44px;
}
button:hover {
background: #2980b9;
}
button:focus {
outline: 2px solid #2980b9;
outline-offset: 2px;
}
.error {
background: #fee;
color: #c33;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.error.visible {
display: block;
}
.back-link {
display: block;
text-align: center;
margin-top: 1rem;
color: #666;
text-decoration: none;
}
.back-link:hover {
color: #3498db;
}
.back-link:focus {
outline: 2px solid #3498db;
outline-offset: 2px;
}
</style>
</head>
<body>
<div class="login-container">
<h1>Admin Login</h1>
<div id="error" class="error" role="alert"></div>
<form id="login-form" action="/admin/login" method="POST">
<div class="form-group">
<label for="username">Username</label>
<input
type="text"
id="username"
name="username"
required
autocomplete="username"
aria-required="true"
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
aria-required="true"
>
</div>
<button type="submit">Login</button>
</form>
<a href="/" class="back-link">Back to Home</a>
</div>
<script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errorDiv = document.getElementById('error');
errorDiv.classList.remove('visible');
const formData = new FormData(e.target);
const data = {
username: formData.get('username'),
password: formData.get('password')
};
try {
const response = await fetch('/admin/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok && result.success) {
window.location.href = result.redirect;
} else {
errorDiv.textContent = result.error || 'Login failed';
errorDiv.classList.add('visible');
}
} catch (err) {
errorDiv.textContent = 'An error occurred. Please try again.';
errorDiv.classList.add('visible');
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,96 @@
<div class="admin-form">
<h1><%= service ? 'Edit Service' : 'Add New Service' %></h1>
<form action="<%= service ? '/admin/services/' + service.id : '/admin/services' %>" method="POST">
<div class="form-group">
<label for="name">
Service Name
<span class="required" aria-label="required">*</span>
</label>
<input
type="text"
id="name"
name="name"
value="<%= service ? service.name : '' %>"
required
aria-required="true"
placeholder="e.g., Facebook"
>
</div>
<div class="form-group">
<label for="url">
Service URL
<span class="required" aria-label="required">*</span>
</label>
<input
type="url"
id="url"
name="url"
value="<%= service ? service.url : '' %>"
required
aria-required="true"
placeholder="https://example.com"
>
</div>
<div class="form-group">
<label for="policy_url">
Privacy Policy URL
<span class="required" aria-label="required">*</span>
</label>
<input
type="url"
id="policy_url"
name="policy_url"
value="<%= service ? service.policy_url : '' %>"
required
aria-required="true"
placeholder="https://example.com/privacy"
>
</div>
<div class="form-group">
<label for="logo_url">Logo URL (optional)</label>
<input
type="url"
id="logo_url"
name="logo_url"
value="<%= service ? service.logo_url : '' %>"
placeholder="https://example.com/logo.png"
>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-success">
<%= service ? 'Update Service' : 'Add Service' %>
</button>
<a href="/admin/dashboard" class="btn">Cancel</a>
</div>
</form>
</div>
<style>
.admin-form {
max-width: 600px;
margin: 0 auto;
}
.admin-form h1 {
margin-bottom: 2rem;
}
.required {
color: #e74c3c;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.form-actions .btn {
min-width: 120px;
}
</style>

300
src/views/layouts/main.ejs Normal file
View File

@@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> - Privacy Policy Analyzer</title>
<meta name="description" content="<%= description %>">
<link rel="canonical" href="<%= canonical %>">
<!-- Open Graph -->
<meta property="og:title" content="<%= title %>">
<meta property="og:description" content="<%= description %>">
<meta property="og:url" content="<%= canonical %>">
<meta property="og:type" content="website">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="<%= title %>">
<meta name="twitter:description" content="<%= description %>">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
/* Reset and base styles */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
/* Skip link */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 100;
text-decoration: none;
}
.skip-link:focus {
top: 0;
}
/* Header */
header {
background: #fff;
border-bottom: 1px solid #e0e0e0;
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 50;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: #2c3e50;
text-decoration: none;
}
.logo:hover {
color: #3498db;
}
/* Navigation */
nav ul {
list-style: none;
display: flex;
gap: 2rem;
}
nav a {
color: #666;
text-decoration: none;
font-weight: 500;
}
nav a:hover,
nav a:focus {
color: #3498db;
outline: none;
}
/* Focus styles */
*:focus {
outline: 2px solid #3498db;
outline-offset: 2px;
}
button:focus,
a:focus {
outline: 2px solid #3498db;
outline-offset: 2px;
}
/* Main content */
main {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
min-height: calc(100vh - 200px);
}
/* Footer */
footer {
background: #2c3e50;
color: #fff;
padding: 2rem 0;
margin-top: 4rem;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
text-align: center;
}
/* Utility classes */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Grade badges */
.grade {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
font-weight: 700;
font-size: 1.2rem;
color: #fff;
}
.grade-a { background: #27ae60; }
.grade-b { background: #2ecc71; }
.grade-c { background: #f1c40f; color: #333; }
.grade-d { background: #e67e22; }
.grade-e { background: #e74c3c; }
/* Cards */
.card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 1.5rem;
margin-bottom: 1rem;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input[type="text"],
input[type="url"],
input[type="password"],
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
input:focus,
textarea:focus {
border-color: #3498db;
outline: none;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}
button,
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
background: #3498db;
color: #fff;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
min-height: 44px;
min-width: 44px;
}
button:hover,
.btn:hover {
background: #2980b9;
}
button:disabled,
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-danger {
background: #e74c3c;
}
.btn-danger:hover {
background: #c0392b;
}
.btn-success {
background: #27ae60;
}
.btn-success:hover {
background: #229954;
}
/* Alerts */
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.alert-error {
background: #fee;
color: #c33;
border: 1px solid #fcc;
}
.alert-success {
background: #efe;
color: #3c3;
border: 1px solid #cfc;
}
</style>
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header role="banner">
<div class="header-content">
<a href="/" class="logo">Privacy Analyzer</a>
<nav role="navigation" aria-label="Main">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/search">Search</a></li>
<li><a href="/admin/login">Admin</a></li>
</ul>
</nav>
</div>
</header>
<main id="main-content" role="main">
<%- body %>
</main>
<footer role="contentinfo">
<div class="footer-content">
<p>&copy; <%= new Date().getFullYear() %> Privacy Policy Analyzer. All rights reserved.</p>
</div>
</footer>
</body>
</html>

271
src/views/public/index.ejs Normal file
View File

@@ -0,0 +1,271 @@
<div class="hero">
<h1>Privacy Policy Analyzer</h1>
<p class="tagline">We read the fine print so you don't have to. Get clear A-E privacy grades for your favorite services.</p>
</div>
<div class="search-section">
<form action="/search" method="GET" class="search-form">
<input
type="search"
name="q"
placeholder="Search services..."
aria-label="Search services"
>
<button type="submit">Search</button>
</form>
</div>
<div class="grade-filters">
<p>Filter by grade:</p>
<div class="filter-buttons">
<a href="/?grade=A" class="filter-btn grade-a">A</a>
<a href="/?grade=B" class="filter-btn grade-b">B</a>
<a href="/?grade=C" class="filter-btn grade-c">C</a>
<a href="/?grade=D" class="filter-btn grade-d">D</a>
<a href="/?grade=E" class="filter-btn grade-e">E</a>
<a href="/" class="filter-btn all">All</a>
</div>
</div>
<% if (services && services.length > 0) { %>
<div class="services-grid">
<% services.forEach(function(service) { %>
<article class="service-card">
<a href="/service/<%= service.id %>" class="service-link" aria-describedby="service-<%= service.id %>-grade">
<% if (service.logo_url) { %>
<img src="<%= service.logo_url %>" alt="<%= service.name %> logo" class="service-logo">
<% } %>
<h2><%= service.name %></h2>
<% if (service.grade) { %>
<div class="grade-display">
<span class="grade grade-<%= service.grade.toLowerCase() %>" id="service-<%= service.id %>-grade">
Grade <%= service.grade %>
</span>
<span class="visually-hidden">Privacy Grade <%= service.grade %></span>
</div>
<% if (service.last_analyzed) { %>
<p class="last-analyzed">
Last analyzed: <%= new Date(service.last_analyzed).toLocaleDateString() %>
</p>
<% } %>
<% if (service.findings && service.findings.negative && service.findings.negative.length > 0) { %>
<ul class="key-findings" aria-label="Key privacy concerns">
<% service.findings.negative.slice(0, 3).forEach(function(finding) { %>
<li><%= finding.title %></li>
<% }); %>
<% if (service.findings.negative.length > 3) { %>
<li class="more">...and <%= service.findings.negative.length - 3 %> more</li>
<% } %>
</ul>
<% } %>
<% } else { %>
<p class="no-grade">Not analyzed yet</p>
<% } %>
</a>
</article>
<% }); %>
</div>
<% if (pagination && pagination.totalPages > 1) { %>
<nav class="pagination" aria-label="Pagination">
<% if (pagination.hasPrev) { %>
<a href="/?page=<%= pagination.currentPage - 1 %>" class="btn">Previous</a>
<% } %>
<span>Page <%= pagination.currentPage %> of <%= pagination.totalPages %></span>
<% if (pagination.hasNext) { %>
<a href="/?page=<%= pagination.currentPage + 1 %>" class="btn">Next</a>
<% } %>
</nav>
<% } %>
<% } else { %>
<div class="empty-state">
<p>No services found. Check back soon!</p>
</div>
<% } %>
<style>
.hero {
text-align: center;
padding: 3rem 0;
}
.hero h1 {
font-size: 3rem;
margin-bottom: 1rem;
color: #2c3e50;
}
.tagline {
font-size: 1.25rem;
color: #666;
max-width: 600px;
margin: 0 auto;
}
.search-section {
max-width: 600px;
margin: 0 auto 2rem;
}
.search-form {
display: flex;
gap: 0.5rem;
}
.search-form input {
flex: 1;
padding: 1rem;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
}
.search-form button {
padding: 1rem 2rem;
min-height: 44px;
}
.grade-filters {
text-align: center;
margin-bottom: 2rem;
}
.grade-filters p {
margin-bottom: 0.5rem;
color: #666;
}
.filter-buttons {
display: flex;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
text-decoration: none;
font-weight: 700;
color: #fff;
min-width: 44px;
min-height: 44px;
}
.filter-btn.all {
background: #95a5a6;
width: auto;
padding: 0 1rem;
border-radius: 22px;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.service-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.service-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.service-link {
display: block;
padding: 1.5rem;
text-decoration: none;
color: inherit;
}
.service-logo {
width: 48px;
height: 48px;
object-fit: contain;
margin-bottom: 1rem;
}
.service-card h2 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: #2c3e50;
}
.grade-display {
margin: 1rem 0;
}
.last-analyzed {
font-size: 0.875rem;
color: #999;
margin-top: 0.5rem;
}
.no-grade {
color: #999;
font-style: italic;
}
.key-findings {
list-style: none;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.key-findings li {
padding: 0.25rem 0;
color: #666;
font-size: 0.875rem;
}
.key-findings li::before {
content: "⚠️ ";
}
.key-findings li.more {
color: #999;
font-style: italic;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #666;
}
@media (max-width: 768px) {
.hero h1 {
font-size: 2rem;
}
.search-form {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,331 @@
<!-- Breadcrumb -->
<nav aria-label="Breadcrumb" class="breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li aria-current="page"><%= service.name %></li>
</ol>
</nav>
<article class="service-detail">
<header class="service-header">
<div class="service-title">
<% if (service.logo_url) { %>
<img src="<%= service.logo_url %>" alt="<%= service.name %> logo" class="service-logo-large">
<% } %>
<div>
<h1><%= service.name %></h1>
<a href="<%= service.url %>" target="_blank" rel="noopener noreferrer" class="service-external-link">
Visit website ↗
</a>
</div>
</div>
<% if (analysis) { %>
<div class="grade-section">
<div class="grade-large grade-<%= analysis.overall_score.toLowerCase() %>">
Grade <%= analysis.overall_score %>
</div>
<p class="last-analyzed">
<strong>Last analyzed:</strong>
<%= new Date(analysis.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) %>
</p>
<% if (analysis.summary) { %>
<p class="summary"><%= analysis.summary %></p>
<% } %>
</div>
<% } else { %>
<div class="no-analysis">
<p>This service hasn't been analyzed yet.</p>
</div>
<% } %>
</header>
<% if (analysis && analysis.findings) { %>
<section class="findings-section" aria-labelledby="negative-findings">
<h2 id="negative-findings">Privacy Concerns</h2>
<% if (analysis.findings.negative && analysis.findings.negative.length > 0) { %>
<ul class="findings-list">
<% analysis.findings.negative.forEach(function(finding) { %>
<li class="finding finding-negative">
<h3><%= finding.title %></h3>
<p><%= finding.description %></p>
</li>
<% }); %>
</ul>
<% } else { %>
<p class="no-findings">No major privacy concerns found. ✓</p>
<% } %>
</section>
<section class="findings-section" aria-labelledby="positive-findings">
<h2 id="positive-findings">Positive Aspects</h2>
<% if (analysis.findings.positive && analysis.findings.positive.length > 0) { %>
<ul class="findings-list">
<% analysis.findings.positive.forEach(function(finding) { %>
<li class="finding finding-positive">
<h3><%= finding.title %></h3>
<p><%= finding.description %></p>
</li>
<% }); %>
</ul>
<% } else { %>
<p class="no-findings">No positive aspects highlighted.</p>
<% } %>
</section>
<% if (analysis.data_types_collected && analysis.data_types_collected.length > 0) { %>
<section class="data-section" aria-labelledby="data-collected">
<h2 id="data-collected">Data Collected</h2>
<ul class="data-list">
<% analysis.data_types_collected.forEach(function(dataType) { %>
<li><%= dataType %></li>
<% }); %>
</ul>
</section>
<% } %>
<% if (analysis.third_parties && analysis.third_parties.length > 0) { %>
<section class="data-section" aria-labelledby="third-parties">
<h2 id="third-parties">Third Parties</h2>
<ul class="third-party-list">
<% analysis.third_parties.forEach(function(party) { %>
<li>
<strong><%= party.name %></strong>
<% if (party.purpose) { %>
<span class="purpose"> - <%= party.purpose %></span>
<% } %>
</li>
<% }); %>
</ul>
</section>
<% } %>
<section class="policy-source">
<h2>Privacy Policy Source</h2>
<p>
<a href="<%= service.policy_url %>" target="_blank" rel="noopener noreferrer">
View original privacy policy ↗
</a>
</p>
</section>
<% } %>
</article>
<style>
.breadcrumb {
margin-bottom: 1rem;
}
.breadcrumb ol {
list-style: none;
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
color: #666;
}
.breadcrumb li:not(:last-child)::after {
content: "/";
margin-left: 0.5rem;
}
.breadcrumb a {
color: #3498db;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.service-detail {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 2rem;
}
.service-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 2px solid #eee;
}
.service-title {
display: flex;
align-items: center;
gap: 1rem;
}
.service-logo-large {
width: 64px;
height: 64px;
object-fit: contain;
}
.service-title h1 {
margin-bottom: 0.5rem;
color: #2c3e50;
}
.service-external-link {
color: #3498db;
text-decoration: none;
font-size: 0.875rem;
}
.service-external-link:hover {
text-decoration: underline;
}
.grade-section {
text-align: center;
}
.grade-large {
display: inline-flex;
align-items: center;
justify-content: center;
width: 120px;
height: 120px;
border-radius: 50%;
font-size: 2.5rem;
font-weight: 700;
color: #fff;
margin-bottom: 1rem;
}
.last-analyzed {
color: #666;
font-size: 0.875rem;
}
.summary {
margin-top: 1rem;
color: #666;
max-width: 400px;
}
.no-analysis {
text-align: center;
color: #999;
padding: 2rem;
}
.findings-section {
margin-bottom: 2rem;
}
.findings-section h2 {
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #eee;
}
.findings-list {
list-style: none;
}
.finding {
padding: 1rem;
margin-bottom: 1rem;
border-radius: 4px;
border-left: 4px solid;
}
.finding-negative {
background: #fef5f5;
border-color: #e74c3c;
}
.finding-positive {
background: #f5fef5;
border-color: #27ae60;
}
.finding h3 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.finding p {
color: #666;
font-size: 0.875rem;
}
.no-findings {
color: #999;
font-style: italic;
padding: 1rem;
}
.data-section {
margin-bottom: 2rem;
}
.data-section h2 {
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #eee;
}
.data-list,
.third-party-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
}
.data-list li {
background: #f0f0f0;
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.875rem;
}
.third-party-list li {
background: #fff;
padding: 0.75rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.875rem;
}
.purpose {
color: #666;
}
.policy-source {
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid #eee;
}
@media (max-width: 768px) {
.service-header {
flex-direction: column;
gap: 1rem;
}
.grade-large {
width: 80px;
height: 80px;
font-size: 1.5rem;
}
}
</style>