feat(thai-frontend-dev): update consent system to better-sqlite3

- Replace Astro DB with better-sqlite3 (bypasses remote SQLite limitation)
- Add consent API with auto-create table pattern
- Update admin dashboard to fetch from API instead of Astro DB
- Add IP hashing and rate limiting for GDPR compliance
- Add seo-utils-mcp-guide skill
- Update legal templates with business placeholders
This commit is contained in:
Kunthawat Greethong
2026-04-01 18:36:51 +07:00
parent e4d41e3ae5
commit 5053ccdba2
6 changed files with 539 additions and 499 deletions

View File

@@ -193,7 +193,6 @@ def ask_analytics_setup():
ASTRO_CONFIG_TEMPLATE = """import {{ defineConfig }} from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import db from '@astrojs/db';
import sitemap from '@astrojs/sitemap';
export default defineConfig({{
@@ -212,13 +211,22 @@ export default defineConfig({{
}},
integrations: [
tailwindcss(),
db(),
sitemap({{
i18n: {{
defaultLocale: '{default_locale}',
}},
}}),
],
vite: {{
optimizeDeps: {{
exclude: ['better-sqlite3']
}},
build: {{
rollupOptions: {{
external: ['better-sqlite3']
}}
}}
}}
}});
"""
@@ -228,25 +236,217 @@ PACKAGE_JSON_TEMPLATE = """{{
"version": "1.0.0",
"scripts": {{
"dev": "astro dev",
"build": "astro build --remote",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"db:push": "astro db push --remote",
"db:seed": "astro db seed"
"astro": "astro"
}},
"dependencies": {{
"astro": "^5.17.1",
"@astrojs/db": "^0.14.0",
"@astrojs/sitemap": "^3.2.0",
"@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1",
"astro-consent": "^1.0.0",
"drizzle-orm": "^0.38.0",
"@libsql/client": "^0.14.0"
"better-sqlite3": "^11.0.0"
}},
"devDependencies": {{
"@types/better-sqlite3": "^7.6.8"
}}
}}
"""
# ==============================================================================
# CONSENT API TEMPLATES (using better-sqlite3 directly)
# ==============================================================================
CONSENT_DB_TEMPLATE = """import Database from 'better-sqlite3';
import {{ join }} from 'path';
import {{ mkdirSync, existsSync }} from 'fs';
const DATA_DIR = join(process.cwd(), 'data');
const DB_PATH = join(DATA_DIR, 'consent.db');
export function getDb() {{
// 1. Create directory if not exists
if (!existsSync(DATA_DIR)) {{
mkdirSync(DATA_DIR, {{ recursive: true }});
}}
// 2. Open database
const db = new Database(DB_PATH);
// 3. Auto-create table (works with remote SQLite!)
db.exec(`
CREATE TABLE IF NOT EXISTS ConsentLog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sessionId TEXT UNIQUE NOT NULL,
timestamp TEXT NOT NULL,
essential INTEGER NOT NULL DEFAULT 0,
analytics INTEGER NOT NULL DEFAULT 0,
marketing INTEGER NOT NULL DEFAULT 0,
policyVersion TEXT NOT NULL,
ipHash TEXT,
userAgent TEXT
)
`);
return db;
}}
// Rate limiting map
const rateLimitMap = new Map<string, {{ count: number; resetTime: number }}>();
const RATE_LIMIT = 10;
const RATE_WINDOW = 60000; // 1 minute
export function checkRateLimit(ip: string): boolean {{
const now = Date.now();
const record = rateLimitMap.get(ip);
if (!record || now > record.resetTime) {{
rateLimitMap.set(ip, {{ count: 1, resetTime: now + RATE_WINDOW }});
return true;
}}
if (record.count >= RATE_LIMIT) {{
return false;
}}
record.count++;
return true;
}}
// IP Hashing for privacy (GDPR compliance)
export async function hashIP(ip: string): Promise<string> {{
try {{
if (crypto.subtle) {{
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex.substring(0, 16);
}}
}} catch {{}}
return `fallback-${Date.now()}`;
}}
"""
CONSENT_API_INDEX_TEMPLATE = """import type {{ APIRoute }} from 'astro';
import {{ getDb, checkRateLimit, hashIP }} from '../../../../lib/db';
export const GET: APIRoute = async ({{ clientAddress }}) => {{
// Rate limit check
const ip = clientAddress || 'unknown';
if (!checkRateLimit(ip)) {{
return new Response(
JSON.stringify({{ error: 'Rate limit exceeded' }}),
{{ status: 429, headers: {{ 'Content-Type': 'application/json' }} }}
);
}}
try {{
const db = getDb();
const logs = db.prepare(`
SELECT * FROM ConsentLog
ORDER BY timestamp DESC
LIMIT 100
`).all();
db.close();
return new Response(
JSON.stringify({{ logs }}),
{{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }}
);
}} catch (error) {{
return new Response(
JSON.stringify({{ error: 'Failed to fetch logs' }}),
{{ status: 500 }}
);
}}
}};
export const POST: APIRoute = async ({{ request, clientAddress }}) => {{
try {{
const body = await request.json();
const {{ sessionId, essential, analytics, marketing, policyVersion, userAgent }} = body;
// Validate required fields
if (!sessionId || essential === undefined || !policyVersion) {{
return new Response(
JSON.stringify({{ error: 'Missing required fields' }}),
{{ status: 400, headers: {{ 'Content-Type': 'application/json' }} }}
);
}}
const db = getDb();
const ipHash = await hashIP(clientAddress || 'unknown');
const timestamp = new Date().toISOString();
// Insert with prepared statement (prevents SQL injection)
const stmt = db.prepare(`
INSERT INTO ConsentLog (sessionId, timestamp, essential, analytics, marketing, policyVersion, ipHash, userAgent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
sessionId,
timestamp,
essential ? 1 : 0,
analytics ? 1 : 0,
marketing ? 1 : 0,
policyVersion,
ipHash,
userAgent || 'unknown'
);
db.close();
return new Response(
JSON.stringify({{ success: true, sessionId }}),
{{ status: 201, headers: {{ 'Content-Type': 'application/json' }} }}
);
}} catch (error) {{
return new Response(
JSON.stringify({{ error: 'Failed to log consent' }}),
{{ status: 500 }}
);
}}
}};
"""
CONSENT_API_DELETE_TEMPLATE = """import type {{ APIRoute }} from 'astro';
import {{ getDb }} from '../../../../lib/db';
export const DELETE: APIRoute = async ({{ params }}) => {{
const {{ sessionId }} = params;
if (!sessionId) {{
return new Response(
JSON.stringify({{ error: 'Session ID required' }}),
{{ status: 400, headers: {{ 'Content-Type': 'application/json' }} }}
);
}}
try {{
const db = getDb();
const stmt = db.prepare('DELETE FROM ConsentLog WHERE sessionId = ?');
const result = stmt.run(sessionId);
db.close();
if (result.changes === 0) {{
return new Response(
JSON.stringify({{ error: 'Record not found' }}),
{{ status: 404 }}
);
}}
return new Response(
JSON.stringify({{ success: true }}),
{{ status: 200, headers: {{ 'Content-Type': 'application/json' }} }}
);
}} catch (error) {{
return new Response(
JSON.stringify({{ error: 'Failed to delete' }}),
{{ status: 500 }}
);
}}
}};
"""
# ... (rest of templates remain the same)
@@ -506,10 +706,12 @@ def create_project(args, languages, default_locale, features):
output_path / "src" / "layouts",
output_path / "src" / "pages",
output_path / "src" / "pages" / default_locale,
output_path / "src" / "pages" / "admin",
output_path / "src" / "pages" / "api" / "consent",
output_path / "src" / "styles",
output_path / "src" / "content" / "blog",
output_path / "src" / "lib",
output_path / "db",
output_path / "data",
]
for d in dirs:
@@ -636,11 +838,42 @@ import Footer from '../components/common/Footer.astro';
print(" ✓ Basic pages created")
# Create Dockerfile
dockerfile = f"""FROM node:20-slim
# Create consent API with better-sqlite3 (bypasses Astro DB limitation)
lib_db_content = CONSENT_DB_TEMPLATE
(output_path / "src" / "lib" / "db.ts").write_text(lib_db_content, encoding="utf-8")
print(" ✓ Consent database library created (better-sqlite3)")
# Create consent API endpoints
consent_index = CONSENT_API_INDEX_TEMPLATE
(output_path / "src" / "pages" / "api" / "consent" / "index.ts").write_text(
consent_index, encoding="utf-8"
)
consent_delete = CONSENT_API_DELETE_TEMPLATE
consent_delete = consent_delete.replace("{{", "{").replace("}}", "}")
consent_delete = consent_delete.replace("[sessionId]", "[sessionId]")
(output_path / "src" / "pages" / "api" / "consent" / "[sessionId].ts").write_text(
consent_delete, encoding="utf-8"
)
print(" ✓ Consent API endpoints created")
# Create admin consent logs page
admin_consent_src = template_dir / "admin-consent-logs.astro"
if admin_consent_src.exists():
shutil.copy(
admin_consent_src,
output_path / "src" / "pages" / "admin" / "consent-logs.astro",
)
print(" ✓ Admin consent logs page copied")
# Create Dockerfile (using alpine for better-sqlite3 native module support)
dockerfile = f"""FROM node:20-alpine
WORKDIR /app
# Install build dependencies for better-sqlite3
RUN apk add --no-cache python3 make g++
# Install dependencies
COPY package*.json ./
RUN npm install
@@ -656,7 +889,7 @@ EXPOSE 80
CMD ["npm", "run", "preview"]
"""
(output_path / "Dockerfile").write_text(dockerfile, encoding="utf-8")
print(" ✓ Dockerfile created")
print(" ✓ Dockerfile created (alpine for better-sqlite3)")
# Create .gitignore
gitignore = """# Dependencies