Compare commits

...

3 commits

Author SHA1 Message Date
53e9dd02fc fixed analytics request 2024-12-23 03:41:14 +01:00
8a52ed518e migrated to DB implementation 2024-12-23 02:50:09 +01:00
62bdced695 added .wrangler folder 2024-12-23 02:49:19 +01:00
5 changed files with 635 additions and 1001 deletions

1
.gitignore vendored
View file

@ -131,3 +131,4 @@ dist
.dev.vars .dev.vars
/.wrangler

1159
package-lock.json generated

File diff suppressed because it is too large Load diff

8
schema.sql Normal file
View file

@ -0,0 +1,8 @@
DROP TABLE IF EXISTS requests;
CREATE TABLE IF NOT EXISTS requests (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME, domain TEXT, method TEXT, path TEXT, country TEXT);
INSERT INTO requests (timestamp, domain, method, path, country)
VALUES
('1734918464464', 'example.com', 'GET', '/home', 'US'),
('1734918464464', 'anotherdomain.com', 'POST', '/login', 'DE'),
('1734918464464', 'yetanotherdomain.com', 'GET', '/products', 'GB'),
('1734918464464', 'coolwebsite.com', 'PUT', '/update', 'FR');

View file

@ -1,41 +1,24 @@
addEventListener('fetch', event => { export default {
event.respondWith(handleRequest(event.request)) async fetch(request, env, context) {
}) const url = new URL(request.url);
const AUTH_KEY = AUTH_KEY_SECRET
const FILE_KEY = 'analytics/requests.json'
const ALLOWED_ORIGINS = [
'https://jonasjones.dev',
'https://www.jonasjones.dev',
'https://blog.jonasjones.dev',
'https://docs.jonasjones.dev',
'https://analytics.jonasjones.dev',
'https://wiki.jonasjones.dev',
'https://kcomebacks.jonasjones.dev',
'https://jonasjonesstudios.com',
'https://lastlovedsyncify.jonasjones.dev',
'https://syncify.jonasjones.dev'
]
async function handleRequest(request) {
const url = new URL(request.url)
if (url.pathname === '/requests/record' && request.method === 'POST') { if (url.pathname === '/requests/record' && request.method === 'POST') {
return handleRecordRequest(request) return this.handleRecordRequest(request, env);
} else if (url.pathname === '/requests/record/ipunknown' && request.method === 'POST') { } else if (url.pathname === '/requests/record/ipunknown' && request.method === 'POST') {
return handleRecordIpRequest(request) return this.handleRecordIpRequest(request, env);
} else if (url.pathname === '/requests/get/count') { } else if (url.pathname === '/requests/get/count') {
return handleGetCountRequest(request) return this.handleGetCountRequest(request, env);
} else if (url.pathname.startsWith('/requests/get')) { } else if (url.pathname.startsWith('/requests/get')) {
return handleGetRequest(url) return this.handleGetRequest(url);
} else if (request.method === 'OPTIONS') { } else if (request.method === 'OPTIONS') {
return handleOptions(request); return this.handleOptions(request);
} }
return new Response('Not Found', { status: 404 }) return new Response('Not Found', { status: 404 });
} },
function handleOptions(request) { // CORS handling for OPTIONS request
async handleOptions(request) {
const origin = request.headers.get('Origin'); const origin = request.headers.get('Origin');
const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin) || ALLOWED_ORIGINS.includes('localhost'); const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin) || ALLOWED_ORIGINS.includes('localhost');
@ -46,80 +29,68 @@ function handleOptions(request) {
'Access-Control-Max-Age': '86400', // Cache the preflight response for 1 day 'Access-Control-Max-Age': '86400', // Cache the preflight response for 1 day
}; };
return new Response(null, { return new Response(null, { headers: headers });
headers: headers, },
});
}
// Handle recording request with the provided data
async handleRecordRequest(request, env) {
const { headers } = request;
const authKey = headers.get('Authorization');
async function handleRecordRequest(request) { if (authKey !== env.AUTH_KEY_SECRET) {
const { headers } = request return new Response('Unauthorized', { status: 401 });
const authKey = headers.get('Authorization')
if (authKey !== AUTH_KEY) {
return new Response('Unauthorized', { status: 401 })
} }
try { try {
const { timestamp, domain, method, path, ipcountry } = await request.json() const { timestamp, domain, method, path, country } = await request.json();
if (!timestamp || !domain || !method || !path || !ipcountry) { if (!timestamp || !domain || !method || !path || !country) {
return new Response('Bad Request: Missing required fields', { status: 400 }) return new Response('Bad Request: Missing required fields', { status: 400 });
} }
const record = { timestamp, domain, method, path, ipcountry } const record = { timestamp, domain, method, path, country };
// Retrieve existing records from R2
let records = await getRecordsFromR2()
if (!records) {
records = []
}
// Add new record to the list
records.push(record)
// Store the updated list back to R2
await uploadRecordsToR2(records)
return new Response('Recorded', { status: 200 })
try {
// Store the new record in the database
await this.storeRecordInDB(record, env);
} catch (error) { } catch (error) {
console.log('Error recording request:', error) return new Response('Error storing record in DB', { status: 500 });
return new Response('Bad Request: Invalid JSON', { status: 400 })
}
} }
async function handleRecordIpRequest(request) { return new Response('Recorded', { status: 200 });
const { headers } = request } catch (error) {
const authKey = headers.get('Authorization') console.log('Error recording request:', error);
return new Response('Bad Request: Invalid JSON', { status: 400 });
}
},
if (authKey !== AUTH_KEY) { // Handle recording request with IP country detection
return new Response('Unauthorized', { status: 401 }) async handleRecordIpRequest(request, env) {
const { headers } = request;
const authKey = headers.get('Authorization');
if (authKey !== env.AUTH_KEY_SECRET) {
return new Response('Unauthorized', { status: 401 });
} }
try { try {
const { timestamp, domain, method, path } = await request.json() const { timestamp, domain, method, path } = await request.json();
if (!timestamp || !domain || !method || !path) { if (!timestamp || !domain || !method || !path) {
return new Response('Bad Request: Missing required fields', { status: 400 }) return new Response('Bad Request: Missing required fields', { status: 400 });
} }
const ipcountry = request.cf.country const country = request.cf.country;
const record = { timestamp, domain, method, path, ipcountry } const record = { timestamp, domain, method, path, country };
// Retrieve existing records from R2 try {
let records = await getRecordsFromR2() // Store the new record in the database
if (!records) { await this.storeRecordInDB(record, env);
records = [] } catch (error) {
return new Response('Error storing record in DB', { status: 500 });
} }
// Add new record to the list
records.push(record)
// Store the updated list back to R2
await uploadRecordsToR2(records)
const origin = request.headers.get('Origin'); const origin = request.headers.get('Origin');
const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin) || ALLOWED_ORIGINS.includes('localhost'); const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin) || ALLOWED_ORIGINS.includes('localhost');
@ -130,115 +101,137 @@ async function handleRecordIpRequest(request) {
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}, },
}) });
} catch (error) { } catch (error) {
console.log('Error recording request:', error) console.log('Error recording request:', error);
return new Response('Bad Request: Invalid JSON', { status: 400 }) return new Response('Bad Request: Invalid JSON', { status: 400 });
}
} }
},
async function handleGetCountRequest(request) { // Handle request to get count of records
async handleGetCountRequest(request, env) {
const origin = request.headers.get('Origin'); const origin = request.headers.get('Origin');
const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin) || ALLOWED_ORIGINS.includes('localhost'); const isAllowedOrigin = ALLOWED_ORIGINS.includes(origin) || ALLOWED_ORIGINS.includes('localhost');
const records = await getRecordsFromR2() || [] try {
const count = records.length // Get number of entries in DB
//const resp = await env.DB.prepare('SELECT COUNT(*) FROM requests').get();
//const count = resp['COUNT(*)'];
const result = await env.DB.prepare("SELECT COUNT(*) AS count FROM requests").all();
console.log(result);
const count = result.results[0].count;
return new Response(JSON.stringify({ count }), { return new Response(JSON.stringify({ count }), {
headers: { 'Content-Type': 'application/json', headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',}, 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}) },
});
} catch (error) {
console.error('Error fetching count from DB:', error);
return new Response('Error fetching count', { status: 500 });
} }
},
async function handleGetRequest(url) { // Handle GET requests with filters (start, end, count, etc.)
const params = new URLSearchParams(url.search) async handleGetRequest(url) {
const params = new URLSearchParams(url.search);
const start = parseInt(params.get('start')) || 0 const start = parseInt(params.get('start')) || 0;
const end = parseInt(params.get('end')) || Number.MAX_SAFE_INTEGER const end = parseInt(params.get('end')) || Number.MAX_SAFE_INTEGER;
const count = Math.min(parseInt(params.get('count')) || 100, 100) const count = Math.min(parseInt(params.get('count')) || 100, 100);
const offset = parseInt(params.get('offset')) || 0 const offset = parseInt(params.get('offset')) || 0;
const domain = params.get('domain') const domain = params.get('domain');
const method = params.get('method') const method = params.get('method');
const path = params.get('path') const path = params.get('path');
const ipcountry = params.get('ipcountry') const country = params.get('country');
let records = await getRecordsFromR2() || [] // Get records from the database with filtering
const records = await this.getRecordsFromDB(env, start, end, domain, method, path, country, offset, count);
let filteredRecords = records return new Response(JSON.stringify(records), {
.filter(record => {
return (
record.timestamp >= start &&
record.timestamp <= end &&
(!domain || record.domain === domain) &&
(!method || record.method === method) &&
(!path || record.path === path) &&
(!ipcountry || record.ipcountry === ipcountry)
)
})
.slice(offset, offset + count)
return new Response(JSON.stringify(filteredRecords), {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) });
} },
async function getRecordsFromR2() { // Store the new record in the database
async storeRecordInDB(record, env) {
await env.DB.prepare('INSERT INTO requests (timestamp, domain, method, path, country) VALUES (?, ?, ?, ?, ?)')
.bind(record.timestamp, record.domain, record.method, record.path, record.country)
.run();
},
// Retrieve filtered records from the database
async getRecordsFromDB(env, start, end, domain, method, path, country, offset, count) {
try { try {
const object = await CDN_BUCKET.get(FILE_KEY) const query = `
if (object === null) { SELECT * FROM requests
return [] WHERE timestamp >= ? AND timestamp <= ?
} ${domain ? 'AND domain = ?' : ''}
const text = await object.text() ${method ? 'AND method = ?' : ''}
return JSON.parse(text) ${path ? 'AND path = ?' : ''}
} catch (error) { ${country ? 'AND country = ?' : ''}
console.error('Error fetching records:', error) ORDER BY timestamp DESC
return [] LIMIT ? OFFSET ?
} `;
} const params = [
start,
end,
domain,
method,
path,
country,
count,
offset,
].filter(param => param !== undefined);
async function uploadRecordsToR2(records) { const records = await env.DB.prepare(query).bind(...params).all();
try {
const json = JSON.stringify(records)
await CDN_BUCKET.put(FILE_KEY, json)
} catch (error) {
console.error('Error uploading records:', error)
}
}
async function getCountryCode(ip) { return records.map(record => ({
timestamp: record.timestamp,
domain: record.domain,
method: record.method,
path: record.path,
country: record.country,
}));
} catch (error) {
console.error('Error fetching records from DB:', error);
return [];
}
},
// Utility functions (e.g., country lookup, hashing, etc.)
async getCountryCode(ip) {
const response = await fetch(`https://ipinfo.io/${ip}/country?token=${IPINFO_TOKEN}`); const response = await fetch(`https://ipinfo.io/${ip}/country?token=${IPINFO_TOKEN}`);
if (!response.ok) { if (!response.ok) {
return "unknown"; return "unknown";
} }
// Since the response is plain text, use response.text()
const countryCode = await response.text(); const countryCode = await response.text();
return countryCode.trim() || "unknown";
},
// Trim any whitespace (like newline characters) from the response async hashString(input) {
const trimmedCountryCode = countryCode.trim();
// Check if the response is a valid country code
if (!trimmedCountryCode) {
return "unknown";
}
return trimmedCountryCode;
}
async function hashString(input) {
// Encode the input string as a Uint8Array (UTF-8)
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const data = encoder.encode(input); const data = encoder.encode(input);
// Hash the data using SHA-256
const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashBuffer = await crypto.subtle.digest('SHA-256', data);
// Convert the hash to a hexadecimal string
const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
},
return hashHex; };
} const ALLOWED_ORIGINS = [
'https://jonasjones.dev',
'https://www.jonasjones.dev',
'https://blog.jonasjones.dev',
'https://docs.jonasjones.dev',
'https://analytics.jonasjones.dev',
'https://wiki.jonasjones.dev',
'https://kcomebacks.jonasjones.dev',
'https://jonasjonesstudios.com',
'https://lastlovedsyncify.jonasjones.dev',
'https://syncify.jonasjones.dev',
];

View file

@ -10,6 +10,11 @@ workers_dev = true
binding = "CDN_BUCKET" binding = "CDN_BUCKET"
bucket_name = "cdn" bucket_name = "cdn"
[[d1_databases]]
binding = "DB"
database_name = "REQUESTS"
database_id = "665c17d4-250a-4a6d-8317-9b4522c7842f"
# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables) # Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
# Note: Use secrets to store sensitive data. # Note: Use secrets to store sensitive data.
# Docs: https://developers.cloudflare.com/workers/platform/environment-variables # Docs: https://developers.cloudflare.com/workers/platform/environment-variables