Implement moreminimore-style consent backend with better-sqlite3
- Add @astrojs/node adapter for hybrid SSR mode - Replace console logging with better-sqlite3 database storage - Create data/ directory for consent.db persistence - Full consent API: POST (log), GET (fetch), DELETE (remove) - Admin dashboard at /admin/consent-logs.astro with: - Password auth via sessionStorage - Stats cards (total, analytics accepted, rejected, rate %) - 100 latest logs table - Export to CSV functionality - Delete individual records - New Dockerfile: node:20-alpine + sqlite-libs runtime - Admin password: Coolm@n1234mo Note: Static pages remain prerendered, only API/admin routes are SSR.
This commit is contained in:
0
.astro/content.db
Normal file
0
.astro/content.db
Normal file
4
.astro/integrations/astro_db/db.d.ts
vendored
Normal file
4
.astro/integrations/astro_db/db.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
// This file is generated by Astro DB
|
||||
declare module 'astro:db' {
|
||||
|
||||
}
|
||||
25
Dockerfile
25
Dockerfile
@@ -1,11 +1,24 @@
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN mkdir -p ./data && ASTRO_DB_REMOTE_URL=file:./data/consent.db npx astro build --remote
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/data ./data
|
||||
|
||||
RUN apk add --no-cache sqlite-libs
|
||||
|
||||
FROM nginx:alpine AS runtime
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV ASTRO_DB_REMOTE_URL=file:/app/data/consent.db
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=80
|
||||
|
||||
CMD ["node", "dist/server/entry.mjs"]
|
||||
@@ -1,9 +1,14 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import node from '@astrojs/node';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://dealplustech.co.th',
|
||||
adapter: node({
|
||||
mode: 'standalone'
|
||||
}),
|
||||
integrations: [
|
||||
tailwind({
|
||||
applyBaseStyles: true,
|
||||
|
||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
238
src/pages/admin/consent-logs.astro
Normal file
238
src/pages/admin/consent-logs.astro
Normal file
@@ -0,0 +1,238 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const ADMIN_PASSWORD = import.meta.env.ADMIN_PASSWORD || 'Coolm@n1234mo';
|
||||
---
|
||||
|
||||
<BaseLayout title="Admin - Consent Logs" description="จัดการบันทึกความยินยอมคุกกี้">
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<header class="bg-white shadow">
|
||||
<div class="container-custom py-6">
|
||||
<h1 class="text-3xl font-bold text-secondary-900">Admin Dashboard - Consent Logs</h1>
|
||||
<p class="text-secondary-700 mt-2">จัดการบันทึกความยินยอมคุกกี้</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container-custom py-8">
|
||||
<div id="login-section" class="max-w-md mx-auto">
|
||||
<div class="bg-white rounded-lg shadow-md p-8">
|
||||
<h2 class="text-2xl font-bold mb-6 text-center text-secondary-900">เข้าสู่ระบบ Admin</h2>
|
||||
<form id="login-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-secondary-700 mb-2">รหัสผ่าน</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
class="w-full px-4 py-2 border border-secondary-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
placeholder="กรอกรหัสผ่าน" />
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="w-full btn-primary py-3">
|
||||
เข้าสู่ระบบ
|
||||
</button>
|
||||
<p id="login-error" class="text-red-600 text-sm mt-4 hidden"></p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dashboard-section" class="hidden">
|
||||
<div class="grid md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-sm font-medium text-secondary-600 mb-2">Total Consents</h3>
|
||||
<p id="stat-total" class="text-3xl font-bold text-secondary-900">0</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-sm font-medium text-secondary-600 mb-2">Accepted Analytics</h3>
|
||||
<p id="stat-analytics" class="text-3xl font-bold text-green-600">0</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-sm font-medium text-secondary-600 mb-2">Rejected Analytics</h3>
|
||||
<p id="stat-rejected" class="text-3xl font-bold text-red-600">0</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-md p-6">
|
||||
<h3 class="text-sm font-medium text-secondary-600 mb-2">Acceptance Rate</h3>
|
||||
<p id="stat-rate" class="text-3xl font-bold text-accent-orange">0%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4 mb-6">
|
||||
<button id="refresh-btn" class="bg-primary text-white px-6 py-2 rounded-lg font-bold hover:bg-primary-600 transition">🔄 รีเฟรช</button>
|
||||
<button id="export-btn" class="bg-green-500 text-white px-6 py-2 rounded-lg font-bold hover:bg-green-600 transition">📥 Export CSV</button>
|
||||
<button id="logout-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg font-bold hover:bg-gray-600 transition">🚪 ออกจากระบบ</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-secondary-200">
|
||||
<h2 class="text-xl font-bold text-secondary-900">บันทึกความยินยอม (100 ล่าสุด)</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-secondary-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase">วันที่/เวลา</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase">Session ID</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase">Essential</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase">Analytics</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase">Marketing</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase">Policy Version</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-secondary-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="logs-table-body" class="bg-white divide-y divide-secondary-200">
|
||||
<tr><td colspan="7" class="px-6 py-4 text-center text-secondary-500">กำลังโหลด...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ADMIN_PASSWORD = 'Coolm@n1234mo';
|
||||
|
||||
function checkAuth() {
|
||||
const session = sessionStorage.getItem('admin-logged-in');
|
||||
if (session === 'true') {
|
||||
showDashboard();
|
||||
}
|
||||
}
|
||||
|
||||
function showDashboard() {
|
||||
document.getElementById('login-section').classList.add('hidden');
|
||||
document.getElementById('dashboard-section').classList.remove('hidden');
|
||||
loadConsentLogs();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById('login-section').classList.remove('hidden');
|
||||
document.getElementById('dashboard-section').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function loadConsentLogs() {
|
||||
try {
|
||||
const response = await fetch('/api/consent');
|
||||
const data = await response.json();
|
||||
const logs = data.logs || [];
|
||||
const tbody = document.getElementById('logs-table-body');
|
||||
|
||||
if (logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="px-6 py-4 text-center text-secondary-500">ยังไม่มีการบันทึกความยินยอม</td></tr>';
|
||||
updateStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = logs.map(log => `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">${new Date(log.timestamp).toLocaleString('th-TH')}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono">${log.sessionId.substring(0, 8)}...</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm"><span class="px-2 py-1 text-xs font-semibold rounded-full ${log.essential ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">${log.essential ? '✓' : '✗'}</span></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm"><span class="px-2 py-1 text-xs font-semibold rounded-full ${log.analytics ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">${log.analytics ? '✓' : '✗'}</span></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm"><span class="px-2 py-1 text-xs font-semibold rounded-full ${log.marketing ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">${log.marketing ? '✓' : '✗'}</span></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">${log.policyVersion}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm"><button onclick="window.deleteConsent('${log.sessionId}')" class="text-red-600 hover:text-red-900 font-medium">ลบ</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
updateStats(logs);
|
||||
} catch (error) {
|
||||
console.error('Error loading logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats(logs) {
|
||||
const total = logs.length;
|
||||
const analytics = logs.filter(l => l.analytics).length;
|
||||
const rejected = total - analytics;
|
||||
const rate = total > 0 ? ((analytics / total) * 100).toFixed(1) : '0';
|
||||
|
||||
document.getElementById('stat-total').textContent = total.toString();
|
||||
document.getElementById('stat-analytics').textContent = analytics.toString();
|
||||
document.getElementById('stat-rejected').textContent = rejected.toString();
|
||||
document.getElementById('stat-rate').textContent = `${rate}%`;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
checkAuth();
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const password = (document.getElementById('password') as HTMLInputElement).value;
|
||||
if (password === ADMIN_PASSWORD) {
|
||||
sessionStorage.setItem('admin-logged-in', 'true');
|
||||
showDashboard();
|
||||
} else {
|
||||
const error = document.getElementById('login-error');
|
||||
if (error) {
|
||||
error.textContent = 'รหัสผ่านไม่ถูกต้อง';
|
||||
error.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('refresh-btn')?.addEventListener('click', loadConsentLogs);
|
||||
document.getElementById('export-btn')?.addEventListener('click', exportToCSV);
|
||||
document.getElementById('logout-btn')?.addEventListener('click', () => {
|
||||
sessionStorage.removeItem('admin-logged-in');
|
||||
showLogin();
|
||||
});
|
||||
});
|
||||
|
||||
async function exportToCSV() {
|
||||
try {
|
||||
const response = await fetch('/api/consent');
|
||||
const data = await response.json();
|
||||
const logs = data.logs || [];
|
||||
|
||||
if (logs.length === 0) {
|
||||
alert('ไม่มีข้อมูลให้ export');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = ['วันที่/เวลา', 'Session ID', 'Essential', 'Analytics', 'Marketing', 'Policy Version', 'IP Hash', 'User Agent'];
|
||||
const csvRows = [headers.join(',')];
|
||||
|
||||
for (const log of logs) {
|
||||
const row = [
|
||||
new Date(log.timestamp).toLocaleString('th-TH'),
|
||||
log.sessionId,
|
||||
log.essential ? 'Yes' : 'No',
|
||||
log.analytics ? 'Yes' : 'No',
|
||||
log.marketing ? 'Yes' : 'No',
|
||||
log.policyVersion,
|
||||
log.ipHash || '',
|
||||
(log.userAgent || '').replace(/,/g, ';')
|
||||
];
|
||||
csvRows.push(row.join(','));
|
||||
}
|
||||
|
||||
const csvContent = '\ufeff' + csvRows.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `consent-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
alert('เกิดข้อผิดพลาดในการ export');
|
||||
}
|
||||
}
|
||||
|
||||
window.deleteConsent = async function(sessionId: string) {
|
||||
if (!confirm('คุณแน่ใจหรือไม่ที่จะลบบันทึกนี้?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/consent/${sessionId}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
alert('ลบบันทึกเรียบร้อยแล้ว');
|
||||
loadConsentLogs();
|
||||
} else {
|
||||
alert('เกิดข้อผิดพลาดในการลบ');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('เกิดข้อผิดพลาดในการลบ');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</BaseLayout>
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '@/layouts/BaseLayout.astro';
|
||||
import Header from '@/components/common/Header.astro';
|
||||
import Footer from '@/components/common/Footer.astro';
|
||||
|
||||
const adminPassword = import.meta.env.ADMIN_PASSWORD || 'Coolm@n1234mo';
|
||||
const password = Astro.url.searchParams.get('password') || '';
|
||||
const isAuthorized = password === adminPassword;
|
||||
---
|
||||
|
||||
<BaseLayout title="Consent Logs - Admin" description="ดูบันทึกการยอมรับ Cookie Consent">
|
||||
<Header slot="header" />
|
||||
|
||||
<main class="bg-gray-100 min-h-screen">
|
||||
<section class="py-12">
|
||||
<div class="container-custom">
|
||||
{isAuthorized ? (
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-secondary-900 mb-8">Consent Logs - Docker Logs</h1>
|
||||
|
||||
<div class="card bg-white p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">วิธีดู Consent Logs</h2>
|
||||
<ul class="list-disc pl-6 text-secondary-700 space-y-2">
|
||||
<li>Consent data ถูก log ไปที่ Docker logs โดยตรง</li>
|
||||
<li>ดู logs ด้วยคำสั่ง: <code class="bg-gray-100 px-2 py-1 rounded text-sm">docker logs <container_name></code></li>
|
||||
<li>หรือใช้: <code class="bg-gray-100 px-2 py-1 rounded text-sm">docker logs -f <container_name> | grep Consent</code></li>
|
||||
<li>Logs จะมี format: <code class="bg-gray-100 px-2 py-1 rounded text-sm">[Consent Log] {{sessionId, essential, analytics, marketing, ...}}</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card bg-white p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">ตัวอย่าง Log Entry</h2>
|
||||
<pre class="bg-gray-900 text-green-400 p-4 rounded-lg overflow-x-auto text-sm">[Consent Log] {"sessionId":"abc123","essential":true,"analytics":true,"marketing":false,"timestamp":"2024-03-01T12:00:00.000Z","ip":"127.0.0.1"}</pre>
|
||||
</div>
|
||||
|
||||
<div class="card bg-white p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Production Setup แนะนำ</h2>
|
||||
<ul class="list-disc pl-6 text-secondary-700 space-y-2">
|
||||
<li>ตั้งค่า Docker logging driver เป็น <code class="bg-gray-100 px-2 py-1 rounded text-sm">json-file</code> และ limit log size</li>
|
||||
<li>ใช้ log aggregation service เช่น <strong>Datadog</strong>, <strong>CloudWatch</strong>, หรือ <strong>ELK Stack</strong></li>
|
||||
<li>หรือเพิ่ม <code class="bg-gray-100 px-2 py-1 rounded text-sm">@astrojs/db</code> พร้อม Turso SQLite สำหรับ persistent storage</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-center">
|
||||
<a href="/admin/consent-logs" class="btn-secondary">ออกจากระบบ</a>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="bg-white rounded-lg shadow-md p-8">
|
||||
<h1 class="text-2xl font-bold text-secondary-900 mb-6 text-center">เข้าสู่ระบบ Admin</h1>
|
||||
|
||||
<form method="GET" action="/admin/consent-logs" class="space-y-4">
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-secondary-700 mb-2">
|
||||
รหัสผ่าน
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
class="w-full px-4 py-3 border border-secondary-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="กรอกรหัสผ่าน"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full btn-primary py-3"
|
||||
>
|
||||
เข้าสู่ระบบ
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer slot="footer" />
|
||||
</BaseLayout>
|
||||
58
src/pages/api/consent/[sessionId].ts
Normal file
58
src/pages/api/consent/[sessionId].ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import Database from 'better-sqlite3';
|
||||
import { join } from 'path';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const DATA_DIR = join(process.cwd(), 'data');
|
||||
const DB_PATH = join(DATA_DIR, 'consent.db');
|
||||
|
||||
function getDb() {
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
const db = new Database(DB_PATH);
|
||||
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;
|
||||
}
|
||||
|
||||
export const DELETE: APIRoute = async ({ params }) => {
|
||||
try {
|
||||
const sessionId = params.sessionId;
|
||||
if (!sessionId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Missing sessionId' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const stmt = db.prepare('DELETE FROM ConsentLog WHERE sessionId = ?');
|
||||
stmt.run(sessionId);
|
||||
db.close();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message: 'Consent deleted' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting consent:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to delete consent' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,74 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import Database from 'better-sqlite3';
|
||||
import { join } from 'path';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const DATA_DIR = join(process.cwd(), 'data');
|
||||
const DB_PATH = join(DATA_DIR, 'consent.db');
|
||||
|
||||
function getDb() {
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
const db = new Database(DB_PATH);
|
||||
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;
|
||||
}
|
||||
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
|
||||
const RATE_LIMIT = 10;
|
||||
const RATE_WINDOW = 60000;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function hashIP(ip: string): Promise<string> {
|
||||
try {
|
||||
if (crypto.subtle) {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip));
|
||||
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 16);
|
||||
}
|
||||
} catch {}
|
||||
return `fallback-${Date.now()}`;
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
const ip = clientAddress || 'unknown';
|
||||
if (!checkRateLimit(ip)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Too many requests' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let body: any = {};
|
||||
const text = await request.text();
|
||||
@@ -10,20 +78,28 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
|
||||
const { sessionId, essential, analytics, marketing, policyVersion, userAgent } = body;
|
||||
|
||||
console.log('[Consent Log]', JSON.stringify({
|
||||
sessionId,
|
||||
essential,
|
||||
analytics,
|
||||
marketing,
|
||||
policyVersion,
|
||||
userAgent,
|
||||
ip: clientAddress || 'unknown',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
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(ip);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
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, message: 'Consent logged' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
JSON.stringify({ success: true, sessionId, message: 'Consent logged' }),
|
||||
{ status: 201, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error logging consent:', error);
|
||||
@@ -35,8 +111,28 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({ logs: [], message: 'Static build - logs go to Docker logs. See: docker logs <container>' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
try {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare('SELECT * FROM ConsentLog ORDER BY timestamp DESC LIMIT 100');
|
||||
const logs = stmt.all();
|
||||
db.close();
|
||||
|
||||
const formattedLogs = logs.map((log: any) => ({
|
||||
...log,
|
||||
essential: log.essential === 1,
|
||||
analytics: log.analytics === 1,
|
||||
marketing: log.marketing === 1
|
||||
}));
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ logs: formattedLogs, message: 'Success' }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
return new Response(
|
||||
JSON.stringify({ logs: [], error: 'Failed to fetch logs' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user