This commit is contained in:
98
lib/db.ts
98
lib/db.ts
@@ -2,39 +2,86 @@ import Database from 'better-sqlite3';
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
|
||||
let db: BetterSqlite3.Database | null = null;
|
||||
let insertStmt: BetterSqlite3.Statement | null = null;
|
||||
const runtimeAnalyticsSalt = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
function getAnalyticsSalt() {
|
||||
return process.env.ANALYTICS_SALT || runtimeAnalyticsSalt;
|
||||
}
|
||||
|
||||
function getDbPath() {
|
||||
if (process.env.VISIT_DB_PATH) {
|
||||
return process.env.VISIT_DB_PATH;
|
||||
}
|
||||
|
||||
const serverStorage = '/server_storage';
|
||||
try {
|
||||
fs.mkdirSync(serverStorage, { recursive: true, mode: 0o700 });
|
||||
fs.accessSync(serverStorage, fs.constants.W_OK);
|
||||
return path.join(serverStorage, 'visitors.db');
|
||||
} catch {
|
||||
const localStorage = path.join(process.cwd(), '.data');
|
||||
fs.mkdirSync(localStorage, { recursive: true, mode: 0o700 });
|
||||
return path.join(localStorage, 'visitors.db');
|
||||
}
|
||||
}
|
||||
|
||||
function createVisitorId(clientAddress: string, userAgent: string) {
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
return crypto
|
||||
.createHmac('sha256', getAnalyticsSalt())
|
||||
.update(`${day}:${clientAddress}:${userAgent}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function ensureSchema(database: BetterSqlite3.Database) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS visits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
visitor_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
visited_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const columns = database
|
||||
.prepare("PRAGMA table_info('visits')")
|
||||
.all() as { name: string; notnull: number }[];
|
||||
const columnNames = new Set(columns.map((column) => column.name));
|
||||
|
||||
if (!columnNames.has('visitor_id')) {
|
||||
database.exec("ALTER TABLE visits ADD COLUMN visitor_id TEXT NOT NULL DEFAULT 'legacy'");
|
||||
columnNames.add('visitor_id');
|
||||
}
|
||||
|
||||
if (!columnNames.has('path')) {
|
||||
throw new Error('visits table is missing required path column');
|
||||
}
|
||||
|
||||
return {
|
||||
hasLegacyIpAddress: columnNames.has('ip_address'),
|
||||
};
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
if (db) return db;
|
||||
|
||||
try {
|
||||
const SERVER_STORAGE = '/server_storage';
|
||||
let dbDir: string;
|
||||
try {
|
||||
fs.accessSync(SERVER_STORAGE, fs.constants.W_OK);
|
||||
dbDir = SERVER_STORAGE;
|
||||
} catch {
|
||||
dbDir = process.cwd();
|
||||
}
|
||||
|
||||
const dbPath = path.join(dbDir, 'visitors.db');
|
||||
const dbPath = getDbPath();
|
||||
db = new Database(dbPath);
|
||||
db.pragma('busy_timeout = 5000');
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS visits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
visited_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
const schema = ensureSchema(db);
|
||||
|
||||
insertStmt = db.prepare(
|
||||
'INSERT INTO visits (ip_address, path) VALUES (?, ?)'
|
||||
);
|
||||
insertStmt = schema.hasLegacyIpAddress
|
||||
? db.prepare("INSERT INTO visits (ip_address, visitor_id, path) VALUES ('redacted', ?, ?)")
|
||||
: db.prepare('INSERT INTO visits (visitor_id, path) VALUES (?, ?)');
|
||||
|
||||
return db;
|
||||
} catch (e) {
|
||||
@@ -45,8 +92,11 @@ function getDb() {
|
||||
}
|
||||
}
|
||||
|
||||
export function logVisit(ip: string, visitPath: string) {
|
||||
export function logVisit(clientAddress: string, userAgent: string, visitPath: string) {
|
||||
const database = getDb();
|
||||
if (!database || !insertStmt) return;
|
||||
insertStmt.run(ip, visitPath);
|
||||
if (!database || !insertStmt) return null;
|
||||
|
||||
const visitorId = createVisitorId(clientAddress, userAgent);
|
||||
insertStmt.run(visitorId, visitPath);
|
||||
return visitorId;
|
||||
}
|
||||
|
||||
10
lib/format.ts
Normal file
10
lib/format.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const postDateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
export function formatPostDate(date: string) {
|
||||
return postDateFormatter.format(new Date(date));
|
||||
}
|
||||
36
lib/mdx.ts
36
lib/mdx.ts
@@ -17,11 +17,38 @@ export type Post = {
|
||||
content: string;
|
||||
};
|
||||
|
||||
function readPostMetadata(data: Record<string, unknown>, slug: string): PostMetadata {
|
||||
if (
|
||||
typeof data.title !== 'string' ||
|
||||
typeof data.date !== 'string' ||
|
||||
typeof data.description !== 'string'
|
||||
) {
|
||||
throw new Error(`Invalid frontmatter for post: ${slug}`);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(Date.parse(data.date))) {
|
||||
throw new Error(`Invalid date for post: ${slug}`);
|
||||
}
|
||||
|
||||
return {
|
||||
title: data.title,
|
||||
date: data.date,
|
||||
description: data.description,
|
||||
slug,
|
||||
tags: Array.isArray(data.tags)
|
||||
? data.tags.filter((tag): tag is string => typeof tag === 'string')
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostSlugs() {
|
||||
if (!fs.existsSync(postsDirectory)) {
|
||||
return [];
|
||||
}
|
||||
return fs.readdirSync(postsDirectory);
|
||||
return fs
|
||||
.readdirSync(postsDirectory, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile())
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string): Post {
|
||||
@@ -43,10 +70,7 @@ export function getPostBySlug(slug: string): Post {
|
||||
const { data, content } = matter(fileContents);
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
...data,
|
||||
slug: realSlug,
|
||||
} as PostMetadata,
|
||||
metadata: readPostMetadata(data, realSlug),
|
||||
content,
|
||||
};
|
||||
}
|
||||
@@ -57,6 +81,6 @@ export function getAllPosts(): PostMetadata[] {
|
||||
.filter((slug) => slug.endsWith('.mdx'))
|
||||
.map((slug) => getPostBySlug(slug).metadata)
|
||||
// Sort posts by date in descending order
|
||||
.sort((post1, post2) => (post1.date > post2.date ? -1 : 1));
|
||||
.sort((post1, post2) => new Date(post2.date).getTime() - new Date(post1.date).getTime());
|
||||
return posts;
|
||||
}
|
||||
|
||||
80
lib/request.ts
Normal file
80
lib/request.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function sanitizeHeaderValue(value: string, maxLength: number) {
|
||||
return value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength) || 'unknown';
|
||||
}
|
||||
|
||||
export function getClientAddress(headers: Headers) {
|
||||
const forwarded = headers.get('x-forwarded-for');
|
||||
if (forwarded) {
|
||||
const addresses = forwarded
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (addresses.length > 0) {
|
||||
// Use the nearest address. This avoids trusting a spoofed left-most value
|
||||
// when Next or a reverse proxy appends the real peer address.
|
||||
return sanitizeHeaderValue(addresses[addresses.length - 1], 128);
|
||||
}
|
||||
}
|
||||
|
||||
const realIp = headers.get('x-real-ip');
|
||||
if (realIp) return sanitizeHeaderValue(realIp, 128);
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function getUserAgent(headers: Headers) {
|
||||
return sanitizeHeaderValue(headers.get('user-agent') || 'unknown', 256);
|
||||
}
|
||||
|
||||
export function normalizeVisitPath(value: unknown) {
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const input = value.trim();
|
||||
if (
|
||||
input.length === 0 ||
|
||||
input.length > 2048 ||
|
||||
!input.startsWith('/') ||
|
||||
input.startsWith('//') ||
|
||||
CONTROL_CHARS.test(input)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(input, 'https://site.local');
|
||||
if (parsed.origin !== 'https://site.local') return null;
|
||||
return `${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSameOriginRequest(request: Request) {
|
||||
const host = request.headers.get('host');
|
||||
if (!host) return false;
|
||||
const requestHost = host.toLowerCase();
|
||||
|
||||
const origin = request.headers.get('origin');
|
||||
if (origin) {
|
||||
try {
|
||||
return new URL(origin).host.toLowerCase() === requestHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const referer = request.headers.get('referer');
|
||||
if (referer) {
|
||||
try {
|
||||
return new URL(referer).host.toLowerCase() === requestHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSite = request.headers.get('sec-fetch-site');
|
||||
return fetchSite === 'same-origin' || fetchSite === 'none';
|
||||
}
|
||||
Reference in New Issue
Block a user