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

0
.astro/content.db Normal file
View File

4
.astro/integrations/astro_db/db.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
// This file is generated by Astro DB
declare module 'astro:db' {
}

View File

@@ -1,11 +1,24 @@
FROM node:20-alpine AS build FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm ci
COPY . . 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 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"]

View File

@@ -1,9 +1,14 @@
// @ts-check
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind'; import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap'; import sitemap from '@astrojs/sitemap';
import node from '@astrojs/node';
export default defineConfig({ export default defineConfig({
site: 'https://dealplustech.co.th', site: 'https://dealplustech.co.th',
adapter: node({
mode: 'standalone'
}),
integrations: [ integrations: [
tailwind({ tailwind({
applyBaseStyles: true, applyBaseStyles: true,

0
data/.gitkeep Normal file
View File

View 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>

View File

@@ -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 &lt;container_name&gt;</code></li>
<li>หรือใช้: <code class="bg-gray-100 px-2 py-1 rounded text-sm">docker logs -f &lt;container_name&gt; | grep Consent</code></li>
<li>Logs จะมี format: <code class="bg-gray-100 px-2 py-1 rounded text-sm">[Consent Log] &#123;&#123;sessionId, essential, analytics, marketing, ...&#125;&#125;</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] &#123;"sessionId":"abc123","essential":true,"analytics":true,"marketing":false,"timestamp":"2024-03-01T12:00:00.000Z","ip":"127.0.0.1"&#125;</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>

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,6 +1,74 @@
import type { APIRoute } from 'astro'; 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 }) => { 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 { try {
let body: any = {}; let body: any = {};
const text = await request.text(); const text = await request.text();
@@ -10,20 +78,28 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
const { sessionId, essential, analytics, marketing, policyVersion, userAgent } = body; const { sessionId, essential, analytics, marketing, policyVersion, userAgent } = body;
console.log('[Consent Log]', JSON.stringify({ if (!sessionId || essential === undefined || !policyVersion) {
sessionId, return new Response(
essential, JSON.stringify({ error: 'Missing required fields' }),
analytics, { status: 400, headers: { 'Content-Type': 'application/json' } }
marketing, );
policyVersion, }
userAgent,
ip: clientAddress || 'unknown', const db = getDb();
timestamp: new Date().toISOString() 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( return new Response(
JSON.stringify({ success: true, message: 'Consent logged' }), JSON.stringify({ success: true, sessionId, message: 'Consent logged' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } } { status: 201, headers: { 'Content-Type': 'application/json' } }
); );
} catch (error) { } catch (error) {
console.error('Error logging consent:', error); console.error('Error logging consent:', error);
@@ -35,8 +111,28 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
}; };
export const GET: APIRoute = async () => { export const GET: APIRoute = async () => {
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( return new Response(
JSON.stringify({ logs: [], message: 'Static build - logs go to Docker logs. See: docker logs <container>' }), JSON.stringify({ logs: formattedLogs, message: 'Success' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } } { 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' } }
);
}
}; };