Initial Commit
This commit is contained in:
22
.env
Normal file
22
.env
Normal 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
22
.env.example
Normal 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
649
AGENTS.md
Normal 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">×</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
32
Dockerfile
Normal 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
713
IMPLEMENTATION_PLAN.md
Normal 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
77
INITIAL-PLANNING.md
Normal 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
5
cookies.txt
Normal 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
92
docker-compose.yml
Normal 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
|
||||||
50
migrations/001_initial.sql
Normal file
50
migrations/001_initial.sql
Normal 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
48
package.json
Normal 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
4
public/favicon.svg
Normal 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
600
src/app.js
Normal 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
15
src/config/database.js
Normal 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
11
src/config/meilisearch.js
Normal 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
13
src/config/openai.js
Normal 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
21
src/config/redis.js
Normal 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
84
src/middleware/auth.js
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
32
src/middleware/errorHandler.js
Normal file
32
src/middleware/errorHandler.js
Normal 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'
|
||||||
|
});
|
||||||
|
};
|
||||||
47
src/middleware/rateLimiter.js
Normal file
47
src/middleware/rateLimiter.js
Normal 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');
|
||||||
41
src/middleware/security.js
Normal file
41
src/middleware/security.js
Normal 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();
|
||||||
|
};
|
||||||
59
src/models/AdminSession.js
Normal file
59
src/models/AdminSession.js
Normal 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
76
src/models/Analysis.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/models/PolicyVersion.js
Normal file
53
src/models/PolicyVersion.js
Normal 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
85
src/models/Service.js
Normal 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
210
src/routes/admin.js
Normal 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
135
src/routes/public.js
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
16
src/scripts/hash-password.js
Normal file
16
src/scripts/hash-password.js
Normal 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();
|
||||||
50
src/scripts/healthcheck.js
Normal file
50
src/scripts/healthcheck.js
Normal 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
55
src/scripts/migrate.js
Normal 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
61
src/scripts/test-ai.js
Normal 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();
|
||||||
37
src/scripts/test-models.js
Normal file
37
src/scripts/test-models.js
Normal 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();
|
||||||
68
src/scripts/test-text-extraction.js
Normal file
68
src/scripts/test-text-extraction.js
Normal 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>© 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
254
src/services/aiAnalyzer.js
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
196
src/services/policyFetcher.js
Normal file
196
src/services/policyFetcher.js
Normal 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 = {
|
||||||
|
' ': ' ',
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
''': "'",
|
||||||
|
'–': '-',
|
||||||
|
'—': '-',
|
||||||
|
'“': '"',
|
||||||
|
'”': '"',
|
||||||
|
'‘': "'",
|
||||||
|
'’': "'",
|
||||||
|
'…': '...',
|
||||||
|
'•': '•',
|
||||||
|
'©': '©',
|
||||||
|
'®': '®',
|
||||||
|
'™': '™'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [entity, char] of Object.entries(entities)) {
|
||||||
|
content = content.replace(new RegExp(entity, 'g'), char);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove numeric entities (e.g., {)
|
||||||
|
content = content.replace(/&#\d+;/g, '');
|
||||||
|
|
||||||
|
// Remove hex entities (e.g., )
|
||||||
|
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
214
src/services/scheduler.js
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/services/searchIndexer.js
Normal file
188
src/services/searchIndexer.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/views/admin/dashboard.ejs
Normal file
162
src/views/admin/dashboard.ejs
Normal 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
194
src/views/admin/login.ejs
Normal 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>
|
||||||
96
src/views/admin/service-form.ejs
Normal file
96
src/views/admin/service-form.ejs
Normal 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
300
src/views/layouts/main.ejs
Normal 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>© <%= new Date().getFullYear() %> Privacy Policy Analyzer. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
271
src/views/public/index.ejs
Normal file
271
src/views/public/index.ejs
Normal 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>
|
||||||
331
src/views/public/service.ejs
Normal file
331
src/views/public/service.ejs
Normal 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>
|
||||||
Reference in New Issue
Block a user