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
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ REDIS_URL="redis://localhost:6379"

# Secrets — replace before running. JWT secrets >= 32 chars, IP salt >= 16 chars.
JWT_SECRET="dev-only-change-me-to-a-32-char-minimum-secret"
IP_HASH_SALT="dev-only-16-chars-min"
IP_HASH_SALT="dev-only-16-chars-min"

# Default link lifetime in days (0 = never expires). Demo uses 7.
LINK_DEFAULT_TTL_DAYS=0
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ src/
│ ├── analytics/ # agrégations SQL, stats cachées
│ ├── docs/ # montage de la route /openapi.json + Scalar
│ ├── health/ # /health (liveness), /ready (readiness)
│ ├── maintenance/ # cron de nettoyage (liens expirés, comptes anciens)
│ └── <module>/ # .routes .controller .service .repository .schemas .types
├── shared/
│ ├── auth/ # password (Argon2id), jwt (jose), tokens (opaques + sha256)
│ ├── cache/ # connexion Redis (ioredis) + CacheService JSON
│ ├── errors/ # AppError + error-handler global
│ ├── middleware/ # authenticate, idempotency (rate-limit optionnel)
│ ├── middleware/ # authenticate, idempotency (rate-limit)
│ ├── queue/ # factory de connexion BullMQ
│ ├── geo/ # résolveur de pays best-effort (null par défaut, pluggable)
│ └── utils/ # short-code (nanoid), classifieur UA maison, validateur URL anti-SSRF
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,6 @@ A few choices that aren't obvious from the code:

Not implemented, deliberately:

- Rate limiting per API key (Redis sliding window) — easy add via `@fastify/rate-limit`.
- Webhook on click events, signed with HMAC-SHA256.
- Real GeoIP via MaxMind GeoLite2, behind an env flag.
- Refresh-token-reuse detection: revoke the entire token chain on replay.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "linkforge",
"version": "1.0.0",
"version": "1.1.1",
"description": "URL shortener API with authentication, API keys, async click tracking and analytics.",
"type": "module",
"license": "MIT",
Expand Down
2 changes: 2 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ services:
generateValue: true
- key: IP_HASH_SALT
generateValue: true
- key: LINK_DEFAULT_TTL_DAYS
value: 7
3 changes: 2 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ export async function buildApp(): Promise<FastifyInstance> {
// Key by API key when present, else by IP.
keyGenerator: (request) =>
typeof request.headers['x-api-key'] === 'string' ? request.headers['x-api-key'] : request.ip,
// Don't rate-limit health probes or the docs UI.
// Don't rate-limit health probes, docs UI, or the test suite.
allowList: (request) =>
process.env['NODE_ENV'] === 'test' ||
request.url.startsWith('/health') ||
request.url.startsWith('/ready') ||
request.url.startsWith('/docs') ||
Expand Down
4 changes: 4 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const envSchema = z.object({

JWT_SECRET: z.string().min(32),
IP_HASH_SALT: z.string().min(16),

// Default link lifetime in days when the client provides no expiresAt.
// 0 disables the default (links never expire). Set to e.g. 7 in production.
LINK_DEFAULT_TTL_DAYS: z.coerce.number().int().min(0).default(0),
});

const parsed = envSchema.safeParse(process.env);
Expand Down
7 changes: 5 additions & 2 deletions src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import { createAuthController } from './auth.controller';
export function authRoutes(app: FastifyInstance): void {
const controller = createAuthController(createAuthService(createAuthRepository(prisma)));

app.post('/auth/register', controller.register);
app.post('/auth/login', controller.login);
// Stricter than the global limit: account creation and login are abuse-prone.
const strict = { rateLimit: { max: 10, timeWindow: '1 minute' } };

app.post('/auth/register', { config: strict }, controller.register);
app.post('/auth/login', { config: strict }, controller.login);
app.post('/auth/refresh', controller.refresh);
app.post('/auth/logout', controller.logout);
app.get('/auth/me', { preHandler: authenticate }, controller.me);
Expand Down
7 changes: 6 additions & 1 deletion src/modules/links/links.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,16 @@ export function createLinksService(repo: LinksRepository, cache: CacheService) {

const code = input.customSlug ?? (await generateUniqueCode());

const defaultExpiry =
env.LINK_DEFAULT_TTL_DAYS > 0
? new Date(Date.now() + env.LINK_DEFAULT_TTL_DAYS * 86_400_000)
: null;

const link = await repo.create({
code,
target: input.target,
userId,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : defaultExpiry,
});

if (idempotencyKey) {
Expand Down
18 changes: 18 additions & 0 deletions src/modules/maintenance/maintenance.processor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Job } from 'bullmq';
import { logger } from '@/config/logger';
import type { MaintenanceRepository } from './maintenance.repository';
import type { CleanupJobData } from './maintenance.types';

const STALE_USER_DAYS = 7;

export function createCleanupProcessor(repo: MaintenanceRepository) {
return async (_job: Job<CleanupJobData>): Promise<void> => {
const now = new Date();
const cutoff = new Date(now.getTime() - STALE_USER_DAYS * 86_400_000);

const expired = await repo.deleteExpiredLinks(now);
const stale = await repo.deleteStaleUsers(cutoff);

logger.info({ expiredLinks: expired.count, staleUsers: stale.count }, 'Cleanup run complete');
};
}
39 changes: 39 additions & 0 deletions src/modules/maintenance/maintenance.queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Queue } from 'bullmq';
import { logger } from '@/config/logger';
import { createQueueConnection } from '@/shared/queue/connection';
import {
CLEANUP_SCHEDULER_ID,
MAINTENANCE_QUEUE_NAME,
type CleanupJobData,
} from './maintenance.types';

let queue: Queue<CleanupJobData> | null = null;

function getQueue(): Queue<CleanupJobData> {
queue ??= new Queue<CleanupJobData>(MAINTENANCE_QUEUE_NAME, {
connection: createQueueConnection(),
defaultJobOptions: { removeOnComplete: 50, removeOnFail: 50 },
});
return queue;
}

/** Idempotent: upserting the same scheduler id just updates the schedule. */
export async function scheduleCleanup(everyMs: number): Promise<void> {
try {
await getQueue().upsertJobScheduler(
CLEANUP_SCHEDULER_ID,
{ every: everyMs },
{ name: 'cleanup', data: { reason: 'scheduled' } },
);
logger.info({ everyMs }, 'Cleanup scheduler registered');
} catch (err) {
logger.error({ err }, 'Failed to register cleanup scheduler');
}
}

export async function closeMaintenanceQueue(): Promise<void> {
if (queue) {
await queue.close();
queue = null;
}
}
19 changes: 19 additions & 0 deletions src/modules/maintenance/maintenance.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { PrismaClient } from '@prisma/client';

const DEMO_EMAIL = 'demo@linkforge.dev';

export type MaintenanceRepository = ReturnType<typeof createMaintenanceRepository>;

export function createMaintenanceRepository(db: PrismaClient) {
return {
deleteExpiredLinks: (now: Date) =>
db.link.deleteMany({
where: { expiresAt: { not: null, lt: now } },
}),

deleteStaleUsers: (cutoff: Date) =>
db.user.deleteMany({
where: { email: { not: DEMO_EMAIL }, createdAt: { lt: cutoff } },
}),
};
}
6 changes: 6 additions & 0 deletions src/modules/maintenance/maintenance.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const MAINTENANCE_QUEUE_NAME = 'maintenance';
export const CLEANUP_SCHEDULER_ID = 'cleanup-scheduler';

export interface CleanupJobData {
reason: 'scheduled';
}
32 changes: 32 additions & 0 deletions src/modules/maintenance/maintenance.worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Worker } from 'bullmq';
import { logger } from '@/config/logger';
import { prisma } from '@/shared/db';
import { createQueueConnection } from '@/shared/queue/connection';
import { MAINTENANCE_QUEUE_NAME, type CleanupJobData } from './maintenance.types';
import { createMaintenanceRepository } from './maintenance.repository';
import { createCleanupProcessor } from './maintenance.processor';

let worker: Worker<CleanupJobData> | null = null;

export function startMaintenanceWorker(): Worker<CleanupJobData> {
if (worker) return worker;

const processor = createCleanupProcessor(createMaintenanceRepository(prisma));
worker = new Worker<CleanupJobData>(MAINTENANCE_QUEUE_NAME, processor, {
connection: createQueueConnection(),
concurrency: 1,
});

worker.on('failed', (job, err) => {
logger.error({ jobId: job?.id, err }, 'Cleanup job failed');
});

return worker;
}

export async function stopMaintenanceWorker(): Promise<void> {
if (worker) {
await worker.close();
worker = null;
}
}
9 changes: 9 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,26 @@ import { disconnectDb } from '@/shared/db';
import { redis } from '@/shared/cache/redis';
import { startClickWorker, stopClickWorker } from '@/modules/tracking/tracking.worker';
import { closeClickQueue } from '@/modules/tracking/tracking.queue';
import {
startMaintenanceWorker,
stopMaintenanceWorker,
} from '@/modules/maintenance/maintenance.worker';
import { scheduleCleanup, closeMaintenanceQueue } from '@/modules/maintenance/maintenance.queue';

async function start(): Promise<void> {
const app = await buildApp();

startClickWorker();
startMaintenanceWorker();
await scheduleCleanup(60 * 60 * 1000); // hourly

const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, 'Shutting down');
await app.close();
await stopClickWorker();
await closeClickQueue();
await stopMaintenanceWorker();
await closeMaintenanceQueue();
await disconnectDb();
redis.disconnect();
process.exit(0);
Expand Down
70 changes: 70 additions & 0 deletions tests/integration/cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import type { Job } from 'bullmq';
import { prisma } from '@/shared/db';
import { redis } from '@/shared/cache/redis';
import { buildTestApp, resetDb } from '../helpers/test-app';
import { createMaintenanceRepository } from '@/modules/maintenance/maintenance.repository';
import { createCleanupProcessor } from '@/modules/maintenance/maintenance.processor';
import type { CleanupJobData } from '@/modules/maintenance/maintenance.types';

describe('Cleanup processor', () => {
const processor = createCleanupProcessor(createMaintenanceRepository(prisma));
const job = { data: { reason: 'scheduled' } } as Job<CleanupJobData>;

beforeAll(async () => {
await buildTestApp();
});

afterAll(async () => {
await prisma.$disconnect();
redis.disconnect();
});

beforeEach(resetDb);

it('deletes expired links but keeps valid ones', async () => {
const user = await prisma.user.create({
data: { email: 'u@example.com', password: 'x' },
});
await prisma.link.create({
data: {
code: 'expired',
target: 'https://e.com',
userId: user.id,
expiresAt: new Date(Date.now() - 1000),
},
});
await prisma.link.create({
data: {
code: 'valid01',
target: 'https://e.com',
userId: user.id,
expiresAt: new Date(Date.now() + 86_400_000),
},
});
await prisma.link.create({
data: { code: 'forever', target: 'https://e.com', userId: user.id },
});

await processor(job);

const remaining = await prisma.link.findMany();
expect(remaining.map((l) => l.code).sort()).toEqual(['forever', 'valid01']);
});

it('prunes stale non-demo users but keeps the demo account', async () => {
const old = new Date(Date.now() - 8 * 86_400_000);
await prisma.user.create({
data: { email: 'demo@linkforge.dev', password: 'x', createdAt: old },
});
await prisma.user.create({
data: { email: 'stale@example.com', password: 'x', createdAt: old },
});
await prisma.user.create({ data: { email: 'recent@example.com', password: 'x' } });

await processor(job);

const emails = (await prisma.user.findMany()).map((u) => u.email).sort();
expect(emails).toEqual(['demo@linkforge.dev', 'recent@example.com']);
});
});
Loading