+ Your Resume.
+ Your Portfolio.
+ In Seconds.
+
+ Upload your resume and let AI transform it into a stunning, professional portfolio that showcases your skills and experience.
+diff --git a/backend/.env.example b/backend/.env.example index 8a16422..a2f470e 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 \ No newline at end of file +PORT=5001 \ No newline at end of file diff --git a/backend/src/config/cors.js b/backend/src/config/cors.js new file mode 100644 index 0000000..4c0ba32 --- /dev/null +++ b/backend/src/config/cors.js @@ -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'], + } +} \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 743244f..e30cc91 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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'; @@ -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, diff --git a/backend/src/models/Analytics.js b/backend/src/models/Analytics.js index ed78e1c..e5cbff4 100644 --- a/backend/src/models/Analytics.js +++ b/backend/src/models/Analytics.js @@ -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({ @@ -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: '' }, @@ -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; @@ -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, @@ -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; } @@ -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); + + 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 diff --git a/backend/src/routes/analytics.js b/backend/src/routes/analytics.js index 8357384..9de1258 100644 --- a/backend/src/routes/analytics.js +++ b/backend/src/routes/analytics.js @@ -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(); @@ -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() }); @@ -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({ @@ -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] diff --git a/backend/src/routes/export.js b/backend/src/routes/export.js index e5dbc7f..9e304fa 100644 --- a/backend/src/routes/export.js +++ b/backend/src/routes/export.js @@ -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(); diff --git a/backend/src/routes/sitemap.js b/backend/src/routes/sitemap.js index 335367b..5f4a8a9 100644 --- a/backend/src/routes/sitemap.js +++ b/backend/src/routes/sitemap.js @@ -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(/\/$/, ''); }; diff --git a/backend/src/utils/analyticsDates.js b/backend/src/utils/analyticsDates.js new file mode 100644 index 0000000..7294eb2 --- /dev/null +++ b/backend/src/utils/analyticsDates.js @@ -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; +} \ No newline at end of file diff --git a/backend/src/utils/referrer.js b/backend/src/utils/referrer.js new file mode 100644 index 0000000..d634eb5 --- /dev/null +++ b/backend/src/utils/referrer.js @@ -0,0 +1,289 @@ +const SOURCE_DEFINITIONS = [ + { id: 'direct', label: 'Direct / Link', category: 'direct', patterns: [] }, + { id: 'chatgpt', label: 'ChatGPT', category: 'ai', patterns: [/^(chat\.openai\.com|chatgpt\.com)$/i] }, + { id: 'claude', label: 'Claude', category: 'ai', patterns: [/^claude\.ai$/i] }, + { id: 'perplexity', label: 'Perplexity', category: 'ai', patterns: [/^perplexity\.ai$/i] }, + { id: 'gemini', label: 'Google Gemini', category: 'ai', patterns: [/^(gemini\.google\.com|bard\.google\.com)$/i] }, + { id: 'copilot', label: 'Microsoft Copilot', category: 'ai', patterns: [/^(copilot\.microsoft\.com|copilot\.cloud\.microsoft)$/i] }, + { id: 'phind', label: 'Phind', category: 'ai', patterns: [/^phind\.com$/i] }, + { id: 'poe', label: 'Poe', category: 'ai', patterns: [/^poe\.com$/i] }, + { id: 'you', label: 'You.com', category: 'ai', patterns: [/^you\.com$/i] }, + { id: 'meta-ai', label: 'Meta AI', category: 'ai', patterns: [/^meta\.ai$/i] }, + { id: 'grok', label: 'Grok', category: 'ai', patterns: [/^grok\.com$/i] }, + { id: 'deepseek', label: 'DeepSeek', category: 'ai', patterns: [/^chat\.deepseek\.com$/i] }, + { id: 'linkedin', label: 'LinkedIn', category: 'social', patterns: [/^(linkedin\.com|lnkd\.in)$/i] }, + { id: 'twitter', label: 'X (Twitter)', category: 'social', patterns: [/^(twitter\.com|x\.com|t\.co)$/i] }, + { id: 'facebook', label: 'Facebook', category: 'social', patterns: [/^(facebook\.com|fb\.com|m\.facebook\.com)$/i] }, + { id: 'instagram', label: 'Instagram', category: 'social', patterns: [/^(instagram\.com|l\.instagram\.com)$/i] }, + { id: 'reddit', label: 'Reddit', category: 'social', patterns: [/^(reddit\.com|old\.reddit\.com)$/i] }, + { id: 'youtube', label: 'YouTube', category: 'social', patterns: [/^(youtube\.com|youtu\.be|m\.youtube\.com)$/i] }, + { id: 'tiktok', label: 'TikTok', category: 'social', patterns: [/^(tiktok\.com|vm\.tiktok\.com)$/i] }, + { id: 'pinterest', label: 'Pinterest', category: 'social', patterns: [/^(pinterest\.com|pin\.it)$/i] }, + { id: 'threads', label: 'Threads', category: 'social', patterns: [/^threads\.net$/i] }, + { id: 'discord', label: 'Discord', category: 'social', patterns: [/^(discord\.com|discord\.gg)$/i] }, + { id: 'telegram', label: 'Telegram', category: 'social', patterns: [/^(t\.me|telegram\.org|web\.telegram\.org)$/i] }, + { id: 'whatsapp', label: 'WhatsApp', category: 'social', patterns: [/^(wa\.me|web\.whatsapp\.com|api\.whatsapp\.com)$/i] }, + { id: 'github', label: 'GitHub', category: 'website', patterns: [/^github\.com$/i] }, + { id: 'medium', label: 'Medium', category: 'website', patterns: [/^(medium\.com|www\.medium\.com)$/i] }, + { id: 'substack', label: 'Substack', category: 'website', patterns: [/^(substack\.com|open\.substack\.com)$/i] }, + { id: 'notion', label: 'Notion', category: 'website', patterns: [/^(notion\.so|www\.notion\.so)$/i] }, + { id: 'google', label: 'Google Search', category: 'search', patterns: [/^google\.[a-z.]+$/i] }, + { id: 'bing', label: 'Bing', category: 'search', patterns: [/^bing\.com$/i] }, + { id: 'duckduckgo', label: 'DuckDuckGo', category: 'search', patterns: [/^duckduckgo\.com$/i] }, + { id: 'yahoo', label: 'Yahoo', category: 'search', patterns: [/^(search\.yahoo\.com|yahoo\.com)$/i] }, + { id: 'ecosia', label: 'Ecosia', category: 'search', patterns: [/^ecosia\.org$/i] }, + { id: 'brave', label: 'Brave Search', category: 'search', patterns: [/^search\.brave\.com$/i] }, + { id: 'gmail', label: 'Gmail', category: 'email', patterns: [/^mail\.google\.com$/i] }, + { id: 'outlook', label: 'Outlook', category: 'email', patterns: [/^(outlook\.live\.com|outlook\.office\.com)$/i] }, + { id: 'yahoo-mail', label: 'Yahoo Mail', category: 'email', patterns: [/^mail\.yahoo\.com$/i] }, + { id: 'protonmail', label: 'Proton Mail', category: 'email', patterns: [/^(mail\.proton\.me|protonmail\.com)$/i] }, +]; + +const LABEL_LOOKUP = new Map( + SOURCE_DEFINITIONS.flatMap((source) => [ + [source.label.toLowerCase(), source], + [source.id, source], + ]) +); + +const LEGACY_DIRECT_LABELS = new Set(['direct', 'direct / link']); + +const UTM_SOURCE_LOOKUP = { + linkedin: 'linkedin', + twitter: 'twitter', + x: 'twitter', + facebook: 'facebook', + instagram: 'instagram', + reddit: 'reddit', + youtube: 'youtube', + tiktok: 'tiktok', + google: 'google', + bing: 'bing', + chatgpt: 'chatgpt', + claude: 'claude', + perplexity: 'perplexity', + gemini: 'gemini', + newsletter: 'newsletter', + email: 'email', + whatsapp: 'whatsapp', + telegram: 'telegram', + github: 'github', +}; + +const UTM_FALLBACK_LABELS = { + newsletter: { id: 'newsletter', label: 'Newsletter', category: 'email' }, + email: { id: 'email', label: 'Email', category: 'email' }, +}; + +const MAX_REFERRER_LENGTH = 2048; +const INTERNAL_HOSTS = new Set(['localhost', '127.0.0.1']); + +function stripWww(hostname) { + return hostname.replace(/^www\./i, ''); +} + +function getConfiguredInternalHosts() { + const hosts = new Set(INTERNAL_HOSTS); + const frontendUrl = process.env.FRONTEND_URL; + + if (frontendUrl) { + try { + const hostname = stripWww(new URL(frontendUrl).hostname); + if (hostname) hosts.add(hostname.toLowerCase()); + } catch { + // Ignore invalid FRONTEND_URL values. + } + } + + return hosts; +} + +function tryParseReferrerHost(raw) { + try { + const url = /^https?:\/\//i.test(raw) ? new URL(raw) : new URL(`https://${raw}`); + return stripWww(url.hostname).toLowerCase(); + } catch { + return null; + } +} + +function isInternalHostname(hostname) { + const normalized = stripWww(hostname).toLowerCase(); + + for (const host of getConfiguredInternalHosts()) { + if (normalized === host || normalized.endsWith(`.${host}`)) { + return true; + } + } + + return false; +} + +function isInternalReferrer(raw) { + if (!raw || raw.startsWith('utm:')) { + return false; + } + + const host = tryParseReferrerHost(raw); + return host ? isInternalHostname(host) : false; +} + +function formatHostname(hostname) { + const parts = stripWww(hostname).split('.').filter(Boolean); + if (parts.length === 0) return hostname; + + const base = parts.length >= 2 ? parts[parts.length - 2] : parts[0]; + return base + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function matchSourceDefinition(hostname) { + return SOURCE_DEFINITIONS.find((source) => + source.patterns.some((pattern) => pattern.test(hostname)) + ); +} + +function buildResult(source, domain = null) { + return { + id: source.id, + label: source.label, + category: source.category, + domain, + }; +} + +function getCanonicalReferrerMeta(normalized) { + const definition = SOURCE_DEFINITIONS.find((entry) => entry.id === normalized.id) + || UTM_FALLBACK_LABELS[normalized.id]; + + if (definition) { + return { + id: definition.id, + label: definition.label, + category: definition.category, + domain: normalized.domain, + }; + } + + return normalized; +} + +function normalizeUtmReferrer(raw) { + const parts = raw.split(':'); + const source = (parts[1] || '').trim(); + const medium = (parts[2] || '').trim(); + const campaign = parts.slice(3).join(':').trim(); + const normalizedSource = source.toLowerCase(); + const normalizedMedium = medium.toLowerCase(); + + const mappedId = UTM_SOURCE_LOOKUP[normalizedSource]; + if (mappedId) { + const definition = SOURCE_DEFINITIONS.find((entry) => entry.id === mappedId) + || UTM_FALLBACK_LABELS[mappedId]; + + if (definition) { + return getCanonicalReferrerMeta(buildResult(definition)); + } + } + + const fallbackLabel = [ + source, + medium ? `(${medium})` : '', + campaign ? `· ${campaign}` : '', + ].filter(Boolean).join(' '); + + return { + id: `utm-${normalizedSource || 'unknown'}`, + label: fallbackLabel || 'Campaign', + category: 'campaign', + domain: null, + }; +} + +export function resolveReferrer(rawReferrer) { + return getCanonicalReferrerMeta(normalizeReferrer(sanitizeReferrerInput(rawReferrer))); +} + +export function sanitizeReferrerInput(input) { + if (input == null) { + return ''; + } + + if (typeof input !== 'string') { + return ''; + } + + return input.trim().slice(0, MAX_REFERRER_LENGTH); +} + +export function normalizeReferrer(rawReferrer) { + const raw = sanitizeReferrerInput(rawReferrer); + + if (!raw || LEGACY_DIRECT_LABELS.has(raw.toLowerCase())) { + return buildResult(SOURCE_DEFINITIONS[0]); + } + + const known = LABEL_LOOKUP.get(raw.toLowerCase()); + if (known) { + return buildResult(known); + } + + if (raw.startsWith('utm:')) { + return normalizeUtmReferrer(raw); + } + + if (isInternalReferrer(raw)) { + return buildResult(SOURCE_DEFINITIONS[0]); + } + + try { + const url = /^https?:\/\//i.test(raw) ? new URL(raw) : new URL(`https://${raw}`); + const hostname = stripWww(url.hostname); + const matched = matchSourceDefinition(hostname); + + if (matched) { + return buildResult(matched, hostname); + } + + return { + id: hostname.toLowerCase(), + label: formatHostname(hostname), + category: 'website', + domain: hostname.toLowerCase(), + }; + } catch { + return { + id: 'unknown', + label: raw.length > 48 ? `${raw.slice(0, 45)}...` : raw, + category: 'other', + domain: null, + }; + } +} + +export function aggregateReferrers(referrerEntries = []) { + const totals = new Map(); + + for (const entry of referrerEntries) { + if (!entry || typeof entry.source !== 'string') { + continue; + } + + const normalized = resolveReferrer(entry.source); + const existing = totals.get(normalized.id); + + if (existing) { + existing.count += entry.count || 0; + } else { + totals.set(normalized.id, { + ...normalized, + source: normalized.label, + count: entry.count || 0, + }); + } + } + + return [...totals.values()].sort((a, b) => b.count - a.count); +} diff --git a/backend/test/analyticsDates.test.js b/backend/test/analyticsDates.test.js new file mode 100644 index 0000000..d299f72 --- /dev/null +++ b/backend/test/analyticsDates.test.js @@ -0,0 +1,25 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { getUtcDayStart, isSameUtcDay, shiftUtcDays } from '../src/utils/analyticsDates.js'; + +describe('analyticsDates', () => { + it('normalizes dates to UTC midnight', () => { + const day = getUtcDayStart(new Date('2026-06-06T23:30:00Z')); + + assert.equal(day.toISOString(), '2026-06-06T00:00:00.000Z'); + }); + + it('shifts UTC day buckets without local timezone drift', () => { + const today = getUtcDayStart(new Date('2026-06-06T12:00:00Z')); + const weekAgo = shiftUtcDays(today, -7); + + assert.equal(weekAgo.toISOString(), '2026-05-30T00:00:00.000Z'); + }); + + it('treats legacy local-midnight buckets as the same UTC day', () => { + const utcMidnight = getUtcDayStart(new Date('2026-06-06T12:00:00Z')); + const legacyBucket = new Date('2026-06-06T07:00:00.000Z'); + + assert.equal(isSameUtcDay(legacyBucket, utcMidnight), true); + }); +}); \ No newline at end of file diff --git a/backend/test/cors.test.js b/backend/test/cors.test.js new file mode 100644 index 0000000..839cee1 --- /dev/null +++ b/backend/test/cors.test.js @@ -0,0 +1,43 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { isAllowedCorsOrigin } from '../src/config/cors.js'; + +describe('cors', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv, NODE_ENV: 'development' }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('allows localhost and LAN dev origins', () => { + assert.equal(isAllowedCorsOrigin('http://localhost:5173'), true); + assert.equal(isAllowedCorsOrigin('http://172.30.212.151:5173'), true); + assert.equal(isAllowedCorsOrigin('http://192.168.1.42:5173'), true); + }); + + it('allows portlifyai.app in production', () => { + assert.equal( + isAllowedCorsOrigin('https://portlifyai.app', { isProduction: true }), + true, + ); + }); + + it('allows FRONTEND_URL from env in production', () => { + process.env.FRONTEND_URL = 'https://custom.example.com'; + assert.equal( + isAllowedCorsOrigin('https://custom.example.com', { isProduction: true }), + true, + ); + }); + + it('blocks unknown origins in production', () => { + assert.equal( + isAllowedCorsOrigin('http://172.30.212.151:5173', { isProduction: true }), + false, + ); + }); +}); \ No newline at end of file diff --git a/backend/test/referrer.test.js b/backend/test/referrer.test.js new file mode 100644 index 0000000..4464184 --- /dev/null +++ b/backend/test/referrer.test.js @@ -0,0 +1,153 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + aggregateReferrers, + normalizeReferrer, + resolveReferrer, + sanitizeReferrerInput, +} from '../src/utils/referrer.js'; + +describe('sanitizeReferrerInput', () => { + it('returns empty string for nullish and non-string values', () => { + assert.equal(sanitizeReferrerInput(null), ''); + assert.equal(sanitizeReferrerInput(undefined), ''); + assert.equal(sanitizeReferrerInput(42), ''); + assert.equal(sanitizeReferrerInput({ source: 'x' }), ''); + }); + + it('trims and caps referrer length', () => { + assert.equal(sanitizeReferrerInput(' direct '), 'direct'); + assert.equal(sanitizeReferrerInput(`https://example.com/${'a'.repeat(3000)}`).length, 2048); + }); +}); + +describe('normalizeReferrer', () => { + const originalFrontendUrl = process.env.FRONTEND_URL; + + beforeEach(() => { + process.env.FRONTEND_URL = 'https://portlify.app'; + }); + + afterEach(() => { + process.env.FRONTEND_URL = originalFrontendUrl; + }); + + it('normalizes direct traffic and legacy labels', () => { + assert.deepEqual(normalizeReferrer(''), { + id: 'direct', + label: 'Direct / Link', + category: 'direct', + domain: null, + }); + assert.equal(normalizeReferrer('direct').id, 'direct'); + assert.equal(normalizeReferrer('Direct / Link').id, 'direct'); + }); + + it('detects AI assistants', () => { + assert.deepEqual(normalizeReferrer('https://chatgpt.com/'), { + id: 'chatgpt', + label: 'ChatGPT', + category: 'ai', + domain: 'chatgpt.com', + }); + assert.equal(normalizeReferrer('https://claude.ai/chat').label, 'Claude'); + assert.equal(normalizeReferrer('https://www.perplexity.ai/search').label, 'Perplexity'); + assert.equal(normalizeReferrer('https://gemini.google.com/app').label, 'Google Gemini'); + assert.equal(normalizeReferrer('https://copilot.microsoft.com/').label, 'Microsoft Copilot'); + }); + + it('detects social and search sources', () => { + assert.equal(normalizeReferrer('https://www.linkedin.com/in/user').label, 'LinkedIn'); + assert.equal(normalizeReferrer('https://t.co/abc').label, 'X (Twitter)'); + assert.equal(normalizeReferrer('https://www.google.com/search?q=portfolio').label, 'Google Search'); + assert.equal(normalizeReferrer('https://github.com/user').label, 'GitHub'); + assert.equal(normalizeReferrer('https://mail.google.com/').label, 'Gmail'); + }); + + it('formats unknown websites by hostname', () => { + assert.equal(normalizeReferrer('https://news.ycombinator.com/item').label, 'Ycombinator'); + assert.equal(normalizeReferrer('https://dev.to/post').label, 'Dev'); + }); + + it('normalizes campaign links to canonical known sources', () => { + assert.equal(normalizeReferrer('utm:linkedin:post').label, 'LinkedIn'); + assert.equal(normalizeReferrer('utm:newsletter:email').label, 'Newsletter'); + assert.equal(normalizeReferrer('utm:chatgpt:share').label, 'ChatGPT'); + }); + + it('keeps custom campaign labels for unknown utm sources', () => { + assert.equal(normalizeReferrer('utm:producthunt:launch').label, 'producthunt (launch)'); + assert.equal(normalizeReferrer('utm:partner::spring-drive').label, 'partner · spring-drive'); + }); + + it('treats internal app traffic as direct', () => { + assert.equal(normalizeReferrer('https://portlify.app/dashboard').id, 'direct'); + assert.equal(normalizeReferrer('https://www.portlify.app/ashish').id, 'direct'); + assert.equal(normalizeReferrer('http://localhost:5173/ashish').id, 'direct'); + }); + + it('handles malformed referrer values safely', () => { + assert.equal(normalizeReferrer('not a valid url at all').category, 'other'); + assert.equal(normalizeReferrer(' ').id, 'direct'); + }); +}); + +describe('resolveReferrer', () => { + it('sanitizes, normalizes, and canonicalizes in one step', () => { + assert.deepEqual(resolveReferrer(' https://chatgpt.com/ '), { + id: 'chatgpt', + label: 'ChatGPT', + category: 'ai', + domain: 'chatgpt.com', + }); + assert.equal(resolveReferrer(null).id, 'direct'); + }); +}); + +describe('aggregateReferrers', () => { + it('merges equivalent sources and legacy labels', () => { + const aggregated = aggregateReferrers([ + { source: 'direct', count: 2 }, + { source: '', count: 1 }, + { source: 'Direct / Link', count: 4 }, + { source: 'https://chatgpt.com/', count: 3 }, + { source: 'https://www.chat.openai.com/', count: 2 }, + ]); + + assert.equal(aggregated[0].label, 'Direct / Link'); + assert.equal(aggregated[0].count, 7); + assert.equal(aggregated[1].label, 'ChatGPT'); + assert.equal(aggregated[1].count, 5); + assert.equal(aggregated[1].category, 'ai'); + }); + + it('merges campaign and social variants for the same source', () => { + const aggregated = aggregateReferrers([ + { source: 'utm:linkedin:post', count: 4 }, + { source: 'https://www.linkedin.com/feed/', count: 6 }, + { source: 'utm:linkedin:email', count: 2 }, + ]); + + assert.equal(aggregated.length, 1); + assert.equal(aggregated[0].label, 'LinkedIn'); + assert.equal(aggregated[0].count, 12); + assert.equal(aggregated[0].category, 'social'); + }); + + it('sorts by count descending and ignores invalid entries', () => { + const aggregated = aggregateReferrers([ + { source: 'https://google.com', count: 2 }, + null, + { source: 'https://chatgpt.com', count: 5 }, + { count: 3 }, + ]); + + assert.equal(aggregated[0].label, 'ChatGPT'); + assert.equal(aggregated[1].label, 'Google Search'); + }); + + it('returns an empty array when no valid referrers exist', () => { + assert.deepEqual(aggregateReferrers([]), []); + assert.deepEqual(aggregateReferrers([null, { count: 1 }]), []); + }); +}); \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example index 060d240..d90abe7 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,6 +1,8 @@ # Frontend Environment Variables VITE_CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx -# Backend API base URL — do NOT include /api suffix +# Backend API base URL — do NOT include /api suffix. +# Dev tip: leave unset (or comment out) to proxy /api via Vite — works with LAN IPs. +# VITE_API_URL=http://localhost:5001 VITE_API_URL=https://portlifybackend.techycsr.dev # Frontend app URL for portfolio links and sharing -VITE_APP_URL=https://portlify.techycsr.dev \ No newline at end of file +VITE_APP_URL=https://portlifyai.app \ No newline at end of file diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index f9e9c75..7f13ed4 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -34,6 +34,14 @@ export default [ 'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }] } }, + { + files: ['vite.config.js', 'scripts/**/*.{js,mjs}'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, { ignores: ['dist/**', 'node_modules/**'] } diff --git a/frontend/index.html b/frontend/index.html index 0419adc..c4bf10a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16,27 +16,27 @@ - + - - - - - - - - + + + + + + + + - + - + @@ -51,11 +51,11 @@ - + - + @@ -73,7 +73,7 @@ "@context": "https://schema.org", "@type": "WebApplication", "name": "PortlifyAi", - "url": "https://portlify.techycsr.dev", + "url": "https://portlifyai.app", "description": "PortlifyAi transforms your resume into a stunning, professional portfolio in seconds. Upload PDF, DOC, or DOCX — AI extracts your skills, experience, and projects into a shareable portfolio URL.", "alternateName": ["Portlify AI", "Portlify", "PortlifyAi Portfolio"], "applicationCategory": "BusinessApplication", @@ -108,7 +108,7 @@ "Custom Branding", "Export as ZIP" ], - "screenshot": "https://portlify.techycsr.dev/og-image.png" + "screenshot": "https://portlifyai.app/og-image.png?v=6" } @@ -119,10 +119,10 @@ "@type": "VideoObject", "name": "PortlifyAi Demo — AI Resume to Portfolio Builder", "description": "Watch how PortlifyAi transforms your resume into a professional portfolio website in seconds using AI. Upload your PDF, DOC, or DOCX and get a shareable portfolio URL instantly.", - "thumbnailUrl": "https://portlify.techycsr.dev/og-image.png", + "thumbnailUrl": "https://portlifyai.app/og-image.png?v=6", "uploadDate": "2026-01-15", "contentUrl": "https://github.com/user-attachments/assets/e36d6f10-bb27-4ae8-a2b6-81122ef85994", - "embedUrl": "https://portlify.techycsr.dev", + "embedUrl": "https://portlifyai.app", "duration": "PT1M", "author": { "@type": "Person", @@ -165,7 +165,7 @@
Visit portlify.techycsr.dev to create your portfolio for free.
+Visit portlify.techycsr.dev to create your portfolio for free.
Created by TechyCSR — GitHub | LinkedIn
diff --git a/frontend/package.json b/frontend/package.json index 5094ff0..f9ab4e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,9 @@ "build": "vite build", "lint": "eslint .", "preview": "vite preview", + "og:image": "node scripts/render-og-from-svg.mjs", + "og:image:crop": "node scripts/crop-og-image.mjs", + "og:image:render": "node scripts/render-og-image.mjs", "test": "vitest run" }, "dependencies": { diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png index b1912fd..3bfa852 100644 Binary files a/frontend/public/apple-touch-icon.png and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon-16x16.png b/frontend/public/favicon-16x16.png index 4530a53..64a0dfc 100644 Binary files a/frontend/public/favicon-16x16.png and b/frontend/public/favicon-16x16.png differ diff --git a/frontend/public/favicon-192x192.png b/frontend/public/favicon-192x192.png index 71a9136..ea2d190 100644 Binary files a/frontend/public/favicon-192x192.png and b/frontend/public/favicon-192x192.png differ diff --git a/frontend/public/favicon-32x32.png b/frontend/public/favicon-32x32.png index 31aef26..de721d7 100644 Binary files a/frontend/public/favicon-32x32.png and b/frontend/public/favicon-32x32.png differ diff --git a/frontend/public/favicon-512x512.png b/frontend/public/favicon-512x512.png index 1930b48..b13eaf9 100644 Binary files a/frontend/public/favicon-512x512.png and b/frontend/public/favicon-512x512.png differ diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png index 31aef26..de721d7 100644 Binary files a/frontend/public/favicon.png and b/frontend/public/favicon.png differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 7196d71..9ecac3e 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,3 +1,3 @@ \ No newline at end of file diff --git a/frontend/public/hero.png b/frontend/public/hero.png new file mode 100644 index 0000000..e7342cb Binary files /dev/null and b/frontend/public/hero.png differ diff --git a/frontend/public/logo-2.png b/frontend/public/logo-2.png new file mode 100644 index 0000000..6243c67 Binary files /dev/null and b/frontend/public/logo-2.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 7540604..36cc1f7 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -12,13 +12,13 @@ "categories": ["productivity", "business", "utilities"], "icons": [ { - "src": "/portlify_clean_logo.png", + "src": "/logo-2.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/portlify_clean_logo.png", + "src": "/logo-2.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/frontend/public/og-image-source.html b/frontend/public/og-image-source.html new file mode 100644 index 0000000..ee84267 --- /dev/null +++ b/frontend/public/og-image-source.html @@ -0,0 +1,252 @@ + + + + + + + + +
+ PortlifyAi
+ Upload your resume and let AI transform it into a stunning, professional portfolio that showcases your skills and experience.
+Finish your setup
++ {hasPendingResume + ? 'Review your extracted profile in the editor, then save to unlock the dashboard.' + : 'Upload your resume to unlock overview, analytics, settings, and more.'} +
+{label}
++ {getReferrerCategoryLabel(ref.category)} + {ref.domain ? ` · ${ref.domain}` : ''} +
+{formatNumber(ref.count)}
+{percent}%
+No referrer data yet
-Share your portfolio to see where visitors come from
++ Share your portfolio on LinkedIn, add UTM links, or send it through ChatGPT to see where visitors come from +
- {tip.title} — {tip.desc} -
-