Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ CLOUDINARY_API_SECRET=your_api_secret
OLLAMA_API_KEY=your_ollama_api_key
RAZORPAY_KEY_ID=your_razorpay_key_id
RAZORPAY_KEY_SECRET=your_razorpay_key_secret
FRONTEND_URL=https://portlify.techycsr.dev
FRONTEND_URL=https://portlifyai.app
NODE_ENV=development
PORT=5000
PORT=5001
61 changes: 61 additions & 0 deletions backend/src/config/cors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const PRODUCTION_ORIGINS = [
'https://portlifyai.app',
'https://www.portlifyai.app',
]

const LOCAL_DEV_ORIGIN_PATTERNS = [
/^https?:\/\/localhost(?::\d+)?$/,
/^https?:\/\/127\.0\.0\.1(?::\d+)?$/,
/^https?:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})(?::\d+)?$/,
]

function normalizeOrigin(url) {
if (!url) return null
return url.replace(/\/$/, '')
}

export function getAllowedOrigins() {
const fromEnv = normalizeOrigin(process.env.FRONTEND_URL)
const origins = new Set(PRODUCTION_ORIGINS.map(normalizeOrigin))

if (fromEnv) {
origins.add(fromEnv)
}

return [...origins]
}

export function isAllowedCorsOrigin(origin, { isProduction = process.env.NODE_ENV === 'production' } = {}) {
if (!origin) {
return true
}

const normalized = normalizeOrigin(origin)
const allowedOrigins = getAllowedOrigins()

if (allowedOrigins.includes(normalized)) {
return true
}

if (!isProduction) {
return LOCAL_DEV_ORIGIN_PATTERNS.some((pattern) => pattern.test(normalized))
}

return false
}

export function createCorsOptions() {
return {
origin(origin, callback) {
if (isAllowedCorsOrigin(origin)) {
callback(null, true)
return
}

callback(new Error(`CORS blocked for origin: ${origin}`))
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}
}
10 changes: 2 additions & 8 deletions backend/src/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { createCorsOptions } from './config/cors.js';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { clerkMiddleware } from '@clerk/express';
Expand Down Expand Up @@ -30,14 +31,7 @@ cloudinary.config({
api_secret: process.env.CLOUDINARY_API_SECRET
});

const corsOptions = {
origin: process.env.NODE_ENV === 'production'
? process.env.FRONTEND_URL
: ['http://localhost:5173', 'http://localhost:5174', 'http://localhost:3000'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
};
const corsOptions = createCorsOptions();

const parseLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
Expand Down
93 changes: 79 additions & 14 deletions backend/src/models/Analytics.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import mongoose from 'mongoose';
import { getUtcDayStart, isSameUtcDay } from '../utils/analyticsDates.js';
import { normalizeReferrer, resolveReferrer } from '../utils/referrer.js';

// Daily stat tracking
const dailyStatSchema = new mongoose.Schema({
Expand All @@ -10,9 +12,61 @@ const dailyStatSchema = new mongoose.Schema({
// Referrer tracking
const referrerSchema = new mongoose.Schema({
source: { type: String, required: true },
referrerId: { type: String, default: '' },
count: { type: Number, default: 0 }
}, { _id: false });

const MAX_STORED_REFERRERS = 50;
const OTHER_REFERRER_ID = 'other-sites';
const OTHER_REFERRER_LABEL = 'Other sites';

function getReferrerEntryId(entry) {
if (entry.referrerId) {
return entry.referrerId;
}

return normalizeReferrer(entry.source).id;
}

function buildReferrerIndex(referrers) {
const index = new Map();

for (const entry of referrers) {
index.set(getReferrerEntryId(entry), entry);
}

return index;
}

function compactReferrers(referrers) {
if (referrers.length <= MAX_STORED_REFERRERS) {
return referrers;
}

const sorted = [...referrers].sort((a, b) => b.count - a.count);
const kept = sorted.slice(0, MAX_STORED_REFERRERS - 1);
const overflow = sorted.slice(MAX_STORED_REFERRERS - 1);
const overflowCount = overflow.reduce((sum, entry) => sum + entry.count, 0);

if (overflowCount <= 0) {
return kept;
}

const otherEntry = kept.find((entry) => getReferrerEntryId(entry) === OTHER_REFERRER_ID);

if (otherEntry) {
otherEntry.count += overflowCount;
} else {
kept.push({
source: OTHER_REFERRER_LABEL,
referrerId: OTHER_REFERRER_ID,
count: overflowCount,
});
}

return kept;
}

// Location tracking
const locationSchema = new mongoose.Schema({
country: { type: String, default: '' },
Expand Down Expand Up @@ -87,8 +141,7 @@ const analyticsSchema = new mongoose.Schema({

// Method to record a view
analyticsSchema.methods.recordView = async function (visitorHash, device, referrer, location) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const today = getUtcDayStart();

// Increment total views
this.totalViews += 1;
Expand All @@ -104,10 +157,8 @@ analyticsSchema.methods.recordView = async function (visitorHash, device, referr
}
}

// Update daily stats
let todayStat = this.dailyStats.find(s =>
s.date.getTime() === today.getTime()
);
// Update daily stats (normalize legacy local-midnight buckets to UTC)
let todayStat = this.dailyStats.find((s) => isSameUtcDay(s.date, today));
if (!todayStat) {
this.dailyStats.push({
date: today,
Expand All @@ -119,6 +170,9 @@ analyticsSchema.methods.recordView = async function (visitorHash, device, referr
this.dailyStats = this.dailyStats.slice(-90);
}
} else {
if (todayStat.date.getTime() !== today.getTime()) {
todayStat.date = today;
}
todayStat.views += 1;
if (isUnique) todayStat.uniqueViews += 1;
}
Expand All @@ -128,14 +182,25 @@ analyticsSchema.methods.recordView = async function (visitorHash, device, referr
this.devices[device] += 1;
}

// Update referrer stats
if (referrer) {
const existingRef = this.referrers.find(r => r.source === referrer);
if (existingRef) {
existingRef.count += 1;
} else {
this.referrers.push({ source: referrer, count: 1 });
}
// Update referrer stats (always tracked, including direct traffic)
const normalizedReferrer = resolveReferrer(referrer);
const referrerIndex = buildReferrerIndex(this.referrers);
const existingRef = referrerIndex.get(normalizedReferrer.id);
Comment on lines +185 to +188

if (existingRef) {
existingRef.count += 1;
existingRef.source = normalizedReferrer.label;
existingRef.referrerId = normalizedReferrer.id;
} else {
this.referrers.push({
source: normalizedReferrer.label,
referrerId: normalizedReferrer.id,
count: 1,
});
}

if (this.referrers.length > MAX_STORED_REFERRERS) {
this.referrers = compactReferrers(this.referrers);
}

// Update location stats
Expand Down
33 changes: 12 additions & 21 deletions backend/src/routes/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import Analytics from '../models/Analytics.js';
import Profile from '../models/Profile.js';
import User from '../models/User.js';
import authMiddleware, { getUserFromAuth } from '../middleware/auth.js';
import { getUtcDayStart, isSameUtcDay, shiftUtcDays } from '../utils/analyticsDates.js';
import { aggregateReferrers, sanitizeReferrerInput } from '../utils/referrer.js';
import crypto from 'crypto';

const router = express.Router();
Expand All @@ -27,7 +29,7 @@ const getDeviceType = (userAgent) => {
router.post('/track/:username', async (req, res) => {
try {
const { username } = req.params;
const { referrer } = req.body;
const referrer = sanitizeReferrerInput(req.body?.referrer);

// Find profile
const profile = await Profile.findOne({ username: username.toLowerCase() });
Expand Down Expand Up @@ -82,25 +84,19 @@ router.get('/summary', authMiddleware, getUserFromAuth, async (req, res) => {
});
}

// Calculate period stats
const today = new Date();
today.setHours(0, 0, 0, 0);
const today = getUtcDayStart();
const weekStart = shiftUtcDays(today, -6);
const monthStart = shiftUtcDays(today, -29);

const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);

const monthAgo = new Date(today);
monthAgo.setDate(monthAgo.getDate() - 30);

const todayStats = analytics.dailyStats.find(s => s.date.getTime() === today.getTime());
const todayStats = analytics.dailyStats.find((s) => isSameUtcDay(s.date, today));
const todayViews = todayStats ? todayStats.views : 0;

const weekViews = analytics.dailyStats
.filter(s => s.date >= weekAgo)
.filter((s) => getUtcDayStart(s.date) >= weekStart)
.reduce((sum, s) => sum + s.views, 0);

const monthViews = analytics.dailyStats
.filter(s => s.date >= monthAgo)
.filter((s) => getUtcDayStart(s.date) >= monthStart)
.reduce((sum, s) => sum + s.views, 0);

res.json({
Expand Down Expand Up @@ -142,18 +138,13 @@ router.get('/me', authMiddleware, getUserFromAuth, async (req, res) => {
}

// Get last 30 days of daily stats
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
thirtyDaysAgo.setHours(0, 0, 0, 0);
const thirtyDaysAgo = shiftUtcDays(getUtcDayStart(), -29);

const recentDailyStats = analytics.dailyStats
.filter(s => s.date >= thirtyDaysAgo)
.filter((s) => getUtcDayStart(s.date) >= thirtyDaysAgo)
.sort((a, b) => a.date - b.date);

// Top 10 referrers
const topReferrers = [...analytics.referrers]
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const topReferrers = aggregateReferrers(analytics.referrers).slice(0, 10);

// Top 10 locations
const topLocations = [...analytics.locations]
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ Generated from PortlifyAi on ${new Date().toLocaleDateString()}
Feel free to modify the CSS variables in styles.css to change colors.

Generated with ❤️ by PortlifyAi
${process.env.FRONTEND_URL || 'https://portlify.techycsr.dev'}
${process.env.FRONTEND_URL || 'https://portlifyai.app'}
`, { name: 'README.md' });

await archive.finalize();
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/sitemap.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Profile from '../models/Profile.js';
const router = express.Router();

const getFrontendUrl = () => {
const url = process.env.FRONTEND_URL || 'https://portlify.techycsr.dev';
const url = process.env.FRONTEND_URL || 'https://portlifyai.app';
return url.replace(/\/$/, '');
};

Expand Down
13 changes: 13 additions & 0 deletions backend/src/utils/analyticsDates.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export function getUtcDayStart(date = new Date()) {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
}

export function isSameUtcDay(a, b) {
return getUtcDayStart(a).getTime() === getUtcDayStart(b).getTime();
}

export function shiftUtcDays(date, days) {
const shifted = new Date(date.getTime());
shifted.setUTCDate(shifted.getUTCDate() + days);
return shifted;
}
Loading
Loading