Files
CShare/backend/src/services/analytics.ts
T
arsam 5604218d4d Import source: backend/src/services/analytics
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:13:31 +03:30

265 lines
7.8 KiB
TypeScript

import { newUuid, sha256 } from '../utils/crypto.js';
import { store } from './store.js';
import type { DatabaseShape } from '../types.js';
/**
* Record a view or download event directly on the provided database instance.
* Useful for batched/atomic operations within store.update().
*/
export function recordEventInDb(
db: DatabaseShape,
linkId: string,
type: 'view' | 'download',
rawIp?: string,
userAgent?: string,
referrer?: string,
): void {
const hashedIp = rawIp ? sha256(rawIp) : undefined;
const cleanedUserAgent = userAgent || undefined;
const cleanedReferrer = referrer || undefined;
const file = db.files.find((f) => f.linkId === linkId && f.status === 'uploaded');
if (file) {
if (type === 'view') {
file.views += 1;
} else if (type === 'download') {
file.downloads += 1;
}
}
db.events.push({
id: newUuid(),
linkId,
type,
createdAt: new Date().toISOString(),
ip: hashedIp,
userAgent: cleanedUserAgent,
referrer: cleanedReferrer,
});
}
/**
* Standalone helper to record an event.
*/
export async function recordEvent(
linkId: string,
type: 'view' | 'download',
rawIp?: string,
userAgent?: string,
referrer?: string,
): Promise<void> {
await store.update((db) => {
recordEventInDb(db, linkId, type, rawIp, userAgent, referrer);
});
}
/**
* Generates a 30-day daily breakdown of views and downloads for a specific file link.
*/
export async function getDailyBreakdown(linkId: string, days = 30) {
const db = await store.read();
const now = new Date();
const dateMap = new Map<string, { views: number; downloads: number }>();
// Pre-fill the range of days
for (let i = days - 1; i >= 0; i--) {
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
const dateString = d.toISOString().split('T')[0];
dateMap.set(dateString, { views: 0, downloads: 0 });
}
// Filter events matching the link ID
const fileEvents = db.events.filter((e) => e.linkId === linkId);
for (const event of fileEvents) {
const dateString = event.createdAt.split('T')[0];
if (dateMap.has(dateString)) {
const counts = dateMap.get(dateString)!;
if (event.type === 'view') counts.views += 1;
if (event.type === 'download') counts.downloads += 1;
}
}
return Array.from(dateMap.entries()).map(([date, counts]) => ({
date,
views: counts.views,
downloads: counts.downloads,
}));
}
/**
* Aggregates and returns top referrers for a specific file link.
*/
export async function getTopReferrers(linkId: string) {
const db = await store.read();
const referrerCounts = new Map<string, number>();
const fileEvents = db.events.filter((e) => e.linkId === linkId);
for (const event of fileEvents) {
const ref = event.referrer || 'Direct';
referrerCounts.set(ref, (referrerCounts.get(ref) || 0) + 1);
}
return Array.from(referrerCounts.entries())
.map(([referrer, count]) => ({ referrer, count }))
.sort((a, b) => b.count - a.count);
}
/**
* Generates the recent activity feed (views, downloads, and uploads) for a user session.
*/
export async function getActivityFeed(sessionId: string, limit = 20) {
const db = await store.read();
const myFiles = db.files.filter((f) => f.status === 'uploaded' && f.sessionId === sessionId);
const myFileMap = new Map(myFiles.map((f) => [f.linkId, f]));
const activities: Array<{
id: string;
type: 'view' | 'download' | 'upload';
fileName: string;
fileId: string;
linkId: string;
createdAt: string;
ip?: string;
userAgent?: string;
referrer?: string;
}> = [];
// Add uploads
for (const file of myFiles) {
activities.push({
id: file.id,
type: 'upload',
fileName: file.originalName,
fileId: file.id,
linkId: file.linkId,
createdAt: file.createdAt,
});
}
// Add views/downloads
for (const event of db.events) {
const file = myFileMap.get(event.linkId);
if (file) {
activities.push({
id: event.id,
type: event.type,
fileName: file.originalName,
fileId: file.id,
linkId: event.linkId,
createdAt: event.createdAt,
ip: event.ip,
userAgent: event.userAgent,
referrer: event.referrer,
});
}
}
// Sort by date descending
return activities
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, limit);
}
function getFileCategory(mimeType: string, filename: string): string {
const mime = mimeType.toLowerCase();
const ext = filename.split('.').pop()?.toLowerCase() || '';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
const archives = new Set(['zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'tgz']);
const archiveMimes = new Set([
'application/zip',
'application/x-tar',
'application/x-gzip',
'application/x-bzip2',
'application/x-7z-compressed',
'application/x-rar-compressed',
]);
if (archives.has(ext) || archiveMimes.has(mime)) return 'archive';
const documents = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'rtf', 'html', 'css', 'json']);
const documentMimes = new Set([
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/html',
'text/css',
'text/csv',
'application/rtf',
'application/json',
]);
if (documents.has(ext) || documentMimes.has(mime)) return 'document';
return 'other';
}
/**
* Returns a storage size and count breakdown grouped by file type category.
*/
export async function getStorageBreakdown(sessionId: string) {
const db = await store.read();
const myFiles = db.files.filter((f) => f.status === 'uploaded' && f.sessionId === sessionId);
const breakdown: Record<string, { count: number; sizeBytes: number }> = {
document: { count: 0, sizeBytes: 0 },
image: { count: 0, sizeBytes: 0 },
video: { count: 0, sizeBytes: 0 },
audio: { count: 0, sizeBytes: 0 },
archive: { count: 0, sizeBytes: 0 },
other: { count: 0, sizeBytes: 0 },
};
for (const file of myFiles) {
const category = getFileCategory(file.mimeType, file.originalName);
breakdown[category].count += 1;
breakdown[category].sizeBytes += file.sizeBytes;
}
return Object.entries(breakdown).map(([category, stats]) => ({
category,
count: stats.count,
sizeBytes: stats.sizeBytes,
}));
}
/**
* Aggregates monthly upload volume (number of uploads and sizes) for the last 6 months.
*/
export async function getMonthlyUploadVolume(sessionId: string, monthsCount = 6) {
const db = await store.read();
const myFiles = db.files.filter((f) => f.status === 'uploaded' && f.sessionId === sessionId);
const volumeMap = new Map<string, { count: number; sizeBytes: number }>();
const now = new Date();
// Pre-fill last N months
for (let i = monthsCount - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
volumeMap.set(monthStr, { count: 0, sizeBytes: 0 });
}
for (const file of myFiles) {
const date = new Date(file.createdAt);
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
if (volumeMap.has(monthStr)) {
const stats = volumeMap.get(monthStr)!;
stats.count += 1;
stats.sizeBytes += file.sizeBytes;
}
}
return Array.from(volumeMap.entries()).map(([month, stats]) => ({
month,
count: stats.count,
sizeBytes: stats.sizeBytes,
}));
}