Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
CREATE TYPE "CardVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'PRIVATE');

ALTER TABLE "cards"
ADD COLUMN "description" TEXT,
ADD COLUMN "slug" TEXT,
ADD COLUMN "visibility" "CardVisibility" NOT NULL DEFAULT 'PUBLIC',
ADD COLUMN "qr_enabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "view_count" INTEGER NOT NULL DEFAULT 0;

UPDATE "cards"
SET "slug" = concat(
trim(both '-' from regexp_replace(lower("title"), '[^a-z0-9]+', '-', 'g')),
'-',
substring("id" from 1 for 8)
)
WHERE "slug" IS NULL;

ALTER TABLE "cards"
ALTER COLUMN "slug" SET NOT NULL;

CREATE UNIQUE INDEX "cards_slug_key" ON "cards"("slug");
CREATE INDEX "cards_user_id_idx" ON "cards"("user_id");
CREATE INDEX "cards_view_count_idx" ON "cards"("view_count");
CREATE INDEX "card_views_card_id_idx" ON "card_views"("card_id");
CREATE INDEX "card_views_owner_id_idx" ON "card_views"("owner_id");
17 changes: 16 additions & 1 deletion apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,21 @@ model PlatformLink {
@@map("platform_links")
}

enum CardVisibility {
PUBLIC
UNLISTED
PRIVATE
}

model Card {
id String @id @default(uuid())
userId String @map("user_id")
title String
description String?
slug String @unique
visibility CardVisibility @default(PUBLIC)
qrEnabled Boolean @default(true) @map("qr_enabled")
viewCount Int @default(0) @map("view_count")
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
Expand All @@ -67,6 +78,8 @@ model Card {
views CardView[]

@@map("cards")
@@index([userId])
@@index([viewCount])
}

model CardLink {
Expand Down Expand Up @@ -114,6 +127,8 @@ model CardView {
viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull)

@@map("card_views")
@@index([cardId])
@@index([ownerId])
}

model FollowLog {
Expand Down Expand Up @@ -194,4 +209,4 @@ model TeamMember{
@@unique([userId, teamId])
@@index([userId])
@@map("team_members")
}
}
10 changes: 6 additions & 4 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ export async function authRoutes(app: FastifyInstance) {
// GitHub OAuth callback
app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
const { code, state } = request.query;
const isMobileOAuth = state?.startsWith('mobile_');
const storedState = request.cookies?.oauth_state;
if (!state || !storedState || state !== storedState) {
if (!state || (!isMobileOAuth && (!storedState || state !== storedState))) {
return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' });
}
reply.clearCookie('oauth_state', { path: '/' });
Expand Down Expand Up @@ -130,7 +131,7 @@ export async function authRoutes(app: FastifyInstance) {

const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });

if (request.query.state?.startsWith('mobile_')) {
if (isMobileOAuth) {
const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI;
return reply.redirect(`${mobileRedirect}#token=${token}`);
}
Expand Down Expand Up @@ -183,8 +184,9 @@ export async function authRoutes(app: FastifyInstance) {
app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
const { code, state } = request.query;

const isMobileOAuth = state?.startsWith('mobile_');
const storedState = request.cookies?.oauth_state;
if (!state || !storedState || state !== storedState) {
if (!state || (!isMobileOAuth && (!storedState || state !== storedState))) {
return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' });
}
reply.clearCookie('oauth_state', { path: '/' });
Expand Down Expand Up @@ -232,7 +234,7 @@ export async function authRoutes(app: FastifyInstance) {

const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });

if (request.query.state?.startsWith('mobile_')) {
if (isMobileOAuth) {
const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI;
return reply.redirect(`${mobileRedirect}#token=${token}`);
}
Expand Down
195 changes: 109 additions & 86 deletions apps/backend/src/routes/cards.ts
Original file line number Diff line number Diff line change
@@ -1,138 +1,161 @@
import { handleDbError } from '../utils/error.util.js';
import { createCardSchema, updateCardSchema } from '../utils/validators.js';
import * as cardService from '../services/cardService'
import { createHash } from 'node:crypto'
import { handleDbError } from '../utils/error.util.js'
import { addPlatformLinkSchema, createCardSchema, updateCardSchema } from '../utils/validators.js'
import * as cardService from '../services/cardService.js'

import type { Card } from '@devcard/shared';
import type { Prisma } from '@prisma/client';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';


interface CreateCardBody {
title: string;
linkIds: string[];
}

interface UpdateCardBody {
title?: string;
linkIds?: string[];
}
import type { CardResponse, CreateCardBody, UpdateCardBody } from '../services/cardService.js'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'

interface CardParams {
id: string;
id: string
}

interface PlatformLink {
id: string;
userId: string;
platform: string;
username: string;
url: string;
displayOrder: number;
createdAt: Date;
function getUserId(request: FastifyRequest): string {
return (request.user as { id: string }).id
}

interface CardLinkWithPlatform {
id: string;
cardId: string;
platformLinkId: string;
displayOrder: number;
platformLink: PlatformLink;
}

interface CardWithLinks {
id: string;
userId: string;
title: string;
isDefault: boolean;
createdAt: Date;
updatedAt: Date;
cardLinks: CardLinkWithPlatform[];
function hashIp(ip: string): string {
return createHash('sha256').update(ip).digest('hex')
}

export async function cardRoutes(app: FastifyInstance): Promise<void> {
app.addHook('preHandler', async (request, reply) => {
const server = request.server as any;
const server = request.server as any
if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return }
if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return }
try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) }
});

// ─── List Cards ───
try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) }
})

app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise<Card[] | void> => {
const userId = (request.user as { id: string }).id;
app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise<CardResponse[] | void> => {
try {
return await cardService.listCards(app, userId)
return await cardService.listCards(app, getUserId(request))
} catch (error) {
return handleDbError(error, request, reply)
}
});

// ─── Create Card ───

app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise<Card | void> => {
const userId = (request.user as { id: string }).id;
const parsed = createCardSchema.safeParse(request.body);
})

if (!parsed.success) {
return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() });
}
app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise<CardResponse | void> => {
const parsed = createCardSchema.safeParse(request.body)
if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() })

try {
const card = await cardService.createCard(app, userId, parsed.data)
const card = await cardService.createCard(app, getUserId(request), parsed.data)
return reply.status(201).send(card)
} catch (error: any) {
if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' })
return handleDbError(error, request, reply)
}
});

// ─── Update Card ───
})

app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise<Card | void> => {
const userId = (request.user as { id: string }).id;
const { id } = request.params;
async function updateCard(request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise<CardResponse | void> {
const parsed = updateCardSchema.safeParse(request.body)
if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() })

try {
const parsed = updateCardSchema.safeParse(request.body)
if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() })
const updated = await cardService.updateCard(app, userId, id, parsed.data)
const updated = await cardService.updateCard(app, getUserId(request), request.params.id, parsed.data)
if (!updated) return reply.status(404).send({ error: 'Card not found' })
return updated
return reply.status(200).send(updated)
} catch (error: any) {
if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' })
return handleDbError(error, request, reply)
}
});

// ─── Delete Card ───
}

app.delete('/:id', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<void> => {
const userId = (request.user as { id: string }).id;
const { id } = request.params;
app.put('/:id', updateCard)
app.put('/:id/update', updateCard)

async function deleteCard(request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<void> {
try {
const res = await cardService.deleteCard(app, userId, id)
const res = await cardService.deleteCard(app, getUserId(request), request.params.id)
if (res && (res as any).code === 'NOT_FOUND') return reply.status(404).send({ error: 'Card not found' })
if (res && (res as any).code === 'LAST_CARD') return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' })
return reply.status(204).send()
} catch (error) {
return handleDbError(error, request, reply)
}
});
}

// ─── Set Default Card ───
app.delete('/:id', deleteCard)
app.delete('/:id/delete', deleteCard)

app.put('/:id/default', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<object | void> => {
const userId = (request.user as { id: string }).id;
const { id } = request.params;

try {
const resp = await cardService.setDefaultCard(app, userId, id)
const resp = await cardService.setDefaultCard(app, getUserId(request), request.params.id)
if (!resp) return reply.status(404).send({ error: 'Card not found' })
return resp
return reply.status(200).send(resp)
} catch (error) {
return handleDbError(error, request, reply)
}
});
})

app.put('/:id/platform-link', async (request: FastifyRequest<{ Params: CardParams; Body: { platformLinkId: string } }>, reply: FastifyReply): Promise<void> => {
const parsed = addPlatformLinkSchema.safeParse(request.body)
if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() })

try {
await cardService.addPlatformLink(app, getUserId(request), request.params.id, parsed.data.platformLinkId)
return reply.status(200).send({ message: 'Platform link added successfully' })
} catch (error: any) {
if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message })
if (error?.code === 'PLATFORM_LINK_NOT_FOUND') return reply.status(403).send({ error: error.message })
if (error?.code === 'LINK_ALREADY_EXISTS') return reply.status(409).send({ error: error.message })
return handleDbError(error, request, reply)
}
})

app.post('/:id/share', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply) => {
try {
return reply.status(200).send(await cardService.shareCard(app, getUserId(request), request.params.id))
} catch (error: any) {
if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message })
if (error?.code === 'CARD_PRIVATE') return reply.status(403).send({ error: error.message })
return handleDbError(error, request, reply)
}
})

app.get('/share/:slug', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => {
try {
const card = await cardService.getSharedCard(app, request.params.slug)
const viewerId = getUserId(request)

await app.prisma.$transaction([
app.prisma.card.update({ where: { id: card.id }, data: { viewCount: { increment: 1 } } }),
app.prisma.cardView.create({
data: {
cardId: card.id,
ownerId: card.userId,
viewerId,
source: 'link',
viewerIp: hashIp(request.ip),
viewerAgent: request.headers['user-agent'] ?? 'unknown',
},
}),
])

return reply.status(200).send(card)
} catch (error: any) {
if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message })
return handleDbError(error, request, reply)
}
})

app.get('/:id/qr', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply) => {
try {
const qrImage = await cardService.generateCardQr(app, getUserId(request), request.params.id)
return reply.type('image/png').send(qrImage)
} catch (error: any) {
if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message })
if (error?.code === 'CARD_PRIVATE' || error?.code === 'QR_DISABLED') return reply.status(403).send({ error: error.message })
return handleDbError(error, request, reply)
}
})

app.get('/:id/analytics', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply) => {
try {
return reply.status(200).send(await cardService.cardAnalytics(app, getUserId(request), request.params.id))
} catch (error: any) {
if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message })
return handleDbError(error, request, reply)
}
})
}
Loading
Loading