- Set Spanish as default language with ephemeral/encrypted privacy focus - Translate all user-facing strings and legal pages to Spanish - Replace Norwegian flag with Spanish flag in footer - Remove Hemmelig/terces.cloud links, add cloudhost.es sponsorship - Rewrite PrivacyPage: zero data collection, ephemeral design emphasis - Rewrite TermsPage: Spanish law, RGPD, paste.es/CloudHost.es references - Update PWA manifest, HTML meta tags, package.json branding - Rename webhook headers to X-Paste-Event / X-Paste-Signature - Update API docs title and contact to paste.es / cloudhost.es Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
254 lines
9.2 KiB
TypeScript
254 lines
9.2 KiB
TypeScript
import { zValidator } from '@hono/zod-validator';
|
|
import { Hono } from 'hono';
|
|
import { isbot } from 'isbot';
|
|
import { z } from 'zod';
|
|
import {
|
|
calculatePercentage,
|
|
createVisitorId,
|
|
getStartDateForTimeRange,
|
|
isAnalyticsEnabled,
|
|
isValidAnalyticsPath,
|
|
} from '../lib/analytics';
|
|
import prisma from '../lib/db';
|
|
import { getClientIp } from '../lib/utils';
|
|
import { authMiddleware, checkAdmin } from '../middlewares/auth';
|
|
|
|
const app = new Hono();
|
|
|
|
const trackSchema = z.object({
|
|
path: z.string().max(255),
|
|
});
|
|
|
|
const timeRangeSchema = z.object({
|
|
timeRange: z.enum(['7d', '14d', '30d']).default('30d'),
|
|
});
|
|
|
|
// POST /api/analytics/track - Public endpoint for visitor tracking
|
|
app.post('/track', zValidator('json', trackSchema), async (c) => {
|
|
if (!isAnalyticsEnabled()) {
|
|
return c.json({ success: false }, 403);
|
|
}
|
|
|
|
const userAgent = c.req.header('user-agent') || '';
|
|
|
|
if (isbot(userAgent)) {
|
|
return c.json({ success: false }, 403);
|
|
}
|
|
|
|
try {
|
|
const { path } = c.req.valid('json');
|
|
|
|
if (!isValidAnalyticsPath(path)) {
|
|
return c.json({ error: 'Invalid path format' }, 400);
|
|
}
|
|
|
|
const uniqueId = createVisitorId(getClientIp(c), userAgent);
|
|
|
|
await prisma.visitorAnalytics.create({
|
|
data: { path, uniqueId },
|
|
});
|
|
|
|
return c.json({ success: true }, 201);
|
|
} catch (error) {
|
|
console.error('Analytics tracking error:', error);
|
|
return c.json({ error: 'Failed to track analytics' }, 500);
|
|
}
|
|
});
|
|
|
|
// GET /api/analytics - Secret analytics (admin only)
|
|
app.get('/', authMiddleware, checkAdmin, zValidator('query', timeRangeSchema), async (c) => {
|
|
const { timeRange } = c.req.valid('query');
|
|
const now = new Date();
|
|
const startDate = getStartDateForTimeRange(timeRange);
|
|
|
|
try {
|
|
// Use aggregations for basic counts - much more efficient than loading all records
|
|
const [aggregates, activeCount, typesCounts, dailyStats, secretRequestStats] =
|
|
await Promise.all([
|
|
// Get total count and sum of views
|
|
prisma.secrets.aggregate({
|
|
where: { createdAt: { gte: startDate } },
|
|
_count: true,
|
|
_sum: { views: true },
|
|
}),
|
|
// Count active (non-expired) secrets
|
|
prisma.secrets.count({
|
|
where: {
|
|
createdAt: { gte: startDate },
|
|
expiresAt: { gt: now },
|
|
},
|
|
}),
|
|
// Get counts for secret types in parallel
|
|
Promise.all([
|
|
prisma.secrets.count({
|
|
where: { createdAt: { gte: startDate }, password: { not: null } },
|
|
}),
|
|
prisma.secrets.count({
|
|
where: {
|
|
createdAt: { gte: startDate },
|
|
ipRange: { not: null },
|
|
NOT: { ipRange: '' },
|
|
},
|
|
}),
|
|
prisma.secrets.count({
|
|
where: { createdAt: { gte: startDate }, isBurnable: true },
|
|
}),
|
|
]),
|
|
// For daily stats, we still need individual records but only select minimal fields
|
|
prisma.secrets.findMany({
|
|
where: { createdAt: { gte: startDate } },
|
|
select: {
|
|
createdAt: true,
|
|
views: true,
|
|
expiresAt: true,
|
|
},
|
|
}),
|
|
// Secret request statistics
|
|
Promise.all([
|
|
prisma.secretRequest.count({
|
|
where: { createdAt: { gte: startDate } },
|
|
}),
|
|
prisma.secretRequest.count({
|
|
where: { createdAt: { gte: startDate }, status: 'fulfilled' },
|
|
}),
|
|
]),
|
|
]);
|
|
|
|
const totalSecrets = aggregates._count;
|
|
const totalViews = aggregates._sum.views || 0;
|
|
const activeSecrets = activeCount;
|
|
const expiredSecrets = totalSecrets - activeSecrets;
|
|
const averageViews = totalSecrets > 0 ? totalViews / totalSecrets : 0;
|
|
|
|
const [passwordProtected, ipRestricted, burnable] = typesCounts;
|
|
const [totalSecretRequests, fulfilledSecretRequests] = secretRequestStats;
|
|
|
|
// Process daily stats from minimal data
|
|
const dailyStatsMap = dailyStats.reduce(
|
|
(acc, secret) => {
|
|
const date = secret.createdAt.toISOString().split('T')[0];
|
|
if (!acc[date]) {
|
|
acc[date] = { date, secrets: 0, views: 0 };
|
|
}
|
|
acc[date].secrets++;
|
|
acc[date].views += secret.views || 0;
|
|
return acc;
|
|
},
|
|
{} as Record<string, { date: string; secrets: number; views: number }>
|
|
);
|
|
|
|
// Calculate expiration stats from minimal data
|
|
const expirationDurations = dailyStats.map(
|
|
(s) => (s.expiresAt.getTime() - s.createdAt.getTime()) / (1000 * 60 * 60)
|
|
);
|
|
const oneHour = expirationDurations.filter((d) => d <= 1).length;
|
|
const oneDay = expirationDurations.filter((d) => d > 1 && d <= 24).length;
|
|
const oneWeekPlus = expirationDurations.filter((d) => d > 24).length;
|
|
|
|
return c.json({
|
|
totalSecrets,
|
|
totalViews,
|
|
activeSecrets,
|
|
expiredSecrets,
|
|
averageViews: parseFloat(averageViews.toFixed(2)),
|
|
dailyStats: Object.values(dailyStatsMap),
|
|
secretTypes: {
|
|
passwordProtected: calculatePercentage(passwordProtected, totalSecrets),
|
|
ipRestricted: calculatePercentage(ipRestricted, totalSecrets),
|
|
burnable: calculatePercentage(burnable, totalSecrets),
|
|
},
|
|
expirationStats: {
|
|
oneHour: calculatePercentage(oneHour, totalSecrets),
|
|
oneDay: calculatePercentage(oneDay, totalSecrets),
|
|
oneWeekPlus: calculatePercentage(oneWeekPlus, totalSecrets),
|
|
},
|
|
secretRequests: {
|
|
total: totalSecretRequests,
|
|
fulfilled: fulfilledSecretRequests,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to fetch analytics data:', error);
|
|
return c.json({ error: 'Failed to fetch analytics data' }, 500);
|
|
}
|
|
});
|
|
|
|
// GET /api/analytics/visitors - Visitor analytics data (admin only)
|
|
app.get('/visitors', authMiddleware, checkAdmin, async (c) => {
|
|
try {
|
|
const analytics = await prisma.visitorAnalytics.findMany({
|
|
orderBy: { timestamp: 'desc' },
|
|
take: 1000,
|
|
});
|
|
return c.json(analytics);
|
|
} catch (error) {
|
|
console.error('Analytics retrieval error:', error);
|
|
return c.json({ error: 'Failed to retrieve analytics' }, 500);
|
|
}
|
|
});
|
|
|
|
// GET /api/analytics/visitors/unique - Aggregated unique visitor data (admin only)
|
|
app.get('/visitors/unique', authMiddleware, checkAdmin, async (c) => {
|
|
try {
|
|
const aggregatedData = await prisma.visitorAnalytics.groupBy({
|
|
by: ['uniqueId', 'path'],
|
|
_count: { uniqueId: true },
|
|
orderBy: { _count: { uniqueId: 'desc' } },
|
|
});
|
|
return c.json(aggregatedData);
|
|
} catch (error) {
|
|
console.error('Aggregated analytics retrieval error:', error);
|
|
return c.json({ error: 'Failed to retrieve aggregated analytics' }, 500);
|
|
}
|
|
});
|
|
|
|
// GET /api/analytics/visitors/daily - Daily visitor statistics (admin only)
|
|
app.get(
|
|
'/visitors/daily',
|
|
authMiddleware,
|
|
checkAdmin,
|
|
zValidator('query', timeRangeSchema),
|
|
async (c) => {
|
|
try {
|
|
const { timeRange } = c.req.valid('query');
|
|
const startDate = getStartDateForTimeRange(timeRange);
|
|
|
|
// Use raw SQL for efficient database-level aggregation
|
|
// This avoids loading all records into memory for high-traffic instances
|
|
const aggregatedData = await prisma.$queryRaw<
|
|
Array<{
|
|
date: string;
|
|
unique_visitors: bigint;
|
|
total_visits: bigint;
|
|
paths: string;
|
|
}>
|
|
>`
|
|
SELECT
|
|
DATE(timestamp) as date,
|
|
COUNT(DISTINCT uniqueId) as unique_visitors,
|
|
COUNT(*) as total_visits,
|
|
GROUP_CONCAT(DISTINCT path) as paths
|
|
FROM visitor_analytics
|
|
WHERE timestamp >= ${startDate}
|
|
GROUP BY DATE(timestamp)
|
|
ORDER BY date ASC
|
|
`;
|
|
|
|
// Convert BigInt to number for JSON serialization
|
|
const result = aggregatedData.map((row) => ({
|
|
date: row.date,
|
|
unique_visitors: Number(row.unique_visitors),
|
|
total_visits: Number(row.total_visits),
|
|
paths: row.paths || '',
|
|
}));
|
|
|
|
return c.json(result);
|
|
} catch (error) {
|
|
console.error('Daily analytics retrieval error:', error);
|
|
return c.json({ error: 'Failed to retrieve daily analytics' }, 500);
|
|
}
|
|
}
|
|
);
|
|
|
|
export default app;
|