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:
Kunthawat
2026-04-01 15:41:46 +07:00
parent a1c9930d49
commit 88fcde1d62
9 changed files with 439 additions and 109 deletions

View 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' } }
);
}
};

View File

@@ -1,29 +1,105 @@
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();
if (text) {
body = JSON.parse(text);
}
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' } }
);
}
};