diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index dc023a2..65f38d3 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -8,6 +8,13 @@ import fastifyStatic from '@fastify/static'; import path from 'path'; import { fileURLToPath } from 'url'; +declare module 'fastify' { + interface FastifyInstance { + authenticate: any; + } +} + + import { prismaPlugin } from './plugins/prisma.js'; import { redisPlugin } from './plugins/redis.js'; import { authRoutes } from './routes/auth.js'; @@ -60,6 +67,9 @@ export async function buildApp() { // ─── Auth Decorator ─── app.decorate('authenticate', async function (request: any, reply: any) { try { + if (!request.headers.authorization && request.cookies && request.cookies.token) { + request.headers.authorization = `Bearer ${request.cookies.token}`; + } await request.jwtVerify(); } catch (err) { reply.status(401).send({ error: 'Unauthorized' }); diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index e12f10a..da47fcb 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -134,7 +134,7 @@ export async function authRoutes(app: FastifyInstance) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); } catch (err) { - app.log.error('GitHub auth error:', err); + app.log.error(err as any, 'GitHub auth error'); return reply.status(500).send({ error: 'Authentication failed' }); } }); @@ -235,7 +235,7 @@ export async function authRoutes(app: FastifyInstance) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`); } catch (err) { - app.log.error('Google auth error:', err); + app.log.error(err as any, 'Google auth error'); return reply.status(500).send({ error: 'Authentication failed' }); } }); @@ -284,6 +284,30 @@ export async function authRoutes(app: FastifyInstance) { reply.clearCookie('token', { path: '/' }); return { message: 'Logged out' }; }); + + // ─── Dev Login Bypass ─── + if (process.env.NODE_ENV !== 'production') { + app.get('/dev-login', async (request: FastifyRequest, reply: FastifyReply) => { + const user = await app.prisma.user.findUnique({ + where: { username: 'devcard-demo' }, + }); + if (!user) { + return reply.status(404).send({ error: 'Demo user not found' }); + } + const token = app.jwt.sign( + { id: user.id, username: user.username }, + { expiresIn: '30d' } + ); + reply.setCookie('token', token, { + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/', + maxAge: 30 * 24 * 60 * 60, + }); + return reply.redirect(`${process.env.PUBLIC_APP_URL || 'http://localhost:5173'}/devcard/analytics`); + }); + } } function generateState(): string { diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index 88db14c..268f282 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,4 +1,5 @@ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { encrypt } from '../utils/encryption.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -81,12 +82,12 @@ export async function connectRoutes(app: FastifyInstance) { const tokenData = (await tokenRes.json()) as any; if (tokenData.error) { - app.log.error('GitHub connect token error:', tokenData); + app.log.error(tokenData, 'GitHub connect token error'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`); } // Encrypt and store the token - const encryptedToken = app.encryption.encrypt(tokenData.access_token); + const encryptedToken = encrypt(tokenData.access_token); await app.prisma.oAuthToken.upsert({ where: { @@ -116,7 +117,7 @@ export async function connectRoutes(app: FastifyInstance) { return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?connected=github`); } catch (err) { - app.log.error('GitHub connect error:', err); + app.log.error(err as any, 'GitHub connect error'); return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=server_error`); } }); diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..8c0f51b --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + autoprefixer: {} + } +}; diff --git a/apps/web/src/routes/devcard/analytics/+page.server.ts b/apps/web/src/routes/devcard/analytics/+page.server.ts new file mode 100644 index 0000000..8898e1b --- /dev/null +++ b/apps/web/src/routes/devcard/analytics/+page.server.ts @@ -0,0 +1,89 @@ +import type { PageServerLoad } from './$types'; +import { redirect } from '@sveltejs/kit'; +import { dev } from '$app/environment'; + +const API_BASE = process.env.BACKEND_URL || 'http://localhost:3000'; + +export interface AnalyticsOverview { + totalViews: number; + viewsToday: number; + uniqueViewers: number; + totalFollows: number; + recentViews: Array<{ + createdAt: string; + source: string; + viewer?: { + avatarUrl?: string; + displayName?: string; + }; + }>; +} + +export interface AnalyticsViews { + meta: { + total: number; + }; + data: Array<{ + createdAt: string; + source: string; + viewerIp?: string; + viewer?: { + displayName?: string; + username?: string; + }; + card?: { + title?: string; + }; + }>; +} + +export const load: PageServerLoad = async ({ fetch, cookies }) => { + const token = cookies.get('token'); + + if (!token) { + if (!dev) { + throw redirect(302, '/'); + } + return { + overview: null, + views: null, + error: 'Please log in to view analytics' + }; + } + + try { + const headers = { Authorization: `Bearer ${token}` }; + const [overviewRes, viewsRes] = await Promise.all([ + fetch(`${API_BASE}/api/analytics/overview`, { headers }), + fetch(`${API_BASE}/api/analytics/views`, { headers }) + ]); + + if (!overviewRes.ok || !viewsRes.ok) { + if (overviewRes.status === 401 || viewsRes.status === 401) { + if (!dev) { + throw redirect(302, '/'); + } + } + return { + overview: null, + views: null, + error: 'Please log in to view analytics' + }; + } + + const overview = (await overviewRes.json()) as AnalyticsOverview; + const views = (await viewsRes.json()) as AnalyticsViews; + + return { overview, views, error: null }; + } catch (err) { + // If it's a redirect thrown by SvelteKit, let it bubble up + if (err && typeof err === 'object' && 'status' in err && 'location' in err) { + throw err; + } + return { + overview: null, + views: null, + error: 'Analytics service unavailable' + }; + } +}; diff --git a/apps/web/src/routes/devcard/analytics/+page.svelte b/apps/web/src/routes/devcard/analytics/+page.svelte new file mode 100644 index 0000000..f530845 --- /dev/null +++ b/apps/web/src/routes/devcard/analytics/+page.svelte @@ -0,0 +1,749 @@ + + + + Analytics Dashboard — DevCard + + +
+
+
+

Analytics Dashboard

+

Track your DevCard performance and reach.

+
+ +
+ + {#if error} +
+
🔒
+

{error}

+

Accessing the dashboard requires an active session.

+
+ Return Home + {#if dev} + Dev Login Bypass + {/if} +
+
+ {:else if overview} + +
+
+ Total Views +
{overview.totalViews}
+ +
+
+ Views Today +
{overview.viewsToday}
+ +
+
+ Unique Viewers +
{overview.uniqueViewers}
+ +
+
+ Total Follows +
{overview.totalFollows}
+ +
+
+ + +
+
+
+

Daily Views (Last 7 Days)

+ Interactive +
+
+ {#if dailyViewsData.length > 0} + + + + + + + + + + + + + + + {#if areaPath} + + {/if} + + + {#if linePath} + + {/if} + + + {#each chartPoints as pt} + + + + {pt.count} + + + {/each} + + + {#each chartPoints as pt} + + {pt.label} + + {/each} + + {:else} +
No view statistics available yet.
+ {/if} +
+
+ +
+
+

Platform Click Rankings

+ Real-time +
+
+ {#each platformClicks as item, index} +
+
+ + #{index + 1} {item.platform} + + {item.count} clicks +
+
+
+
+
+ {/each} + {#if platformClicks.length === 0} +

No platform click details available yet.

+ {/if} +
+
+
+ +
+ +
+

Recent Activity

+
+ {#each overview.recentViews as view} +
+
+ {#if isValidAvatar(view.viewer?.avatarUrl)} + + {:else} +
{view.viewer?.displayName?.charAt(0) || '?'}
+ {/if} +
+
+ {view.viewer?.displayName || 'Anonymous User'} + viewed via {view.source} +
+
{formatDate(view.createdAt)}
+
+ {/each} + {#if overview.recentViews.length === 0} +

No recent activity found.

+ {/if} +
+
+ + +
+
+
+

Detailed View Logs

+ {views?.meta?.total || 0} Total +
+ +
+
+ + + + + + + + + + + + {#each views?.data || [] as view} + + + + + + + + {/each} + +
ViewerCardSourceIP AddressDate
+
+ {view.viewer?.displayName || 'Guest'} + {#if view.viewer?.username} + @{view.viewer.username} + {/if} +
+
{view.card?.title || 'Profile'}{view.source}{view.viewerIp || '—'}{formatDate(view.createdAt)}
+ {#if !views?.data?.length} +
No detailed logs available yet.
+ {/if} +
+
+
+ {/if} +
+ +