This commit is contained in:
2026-07-10 13:26:55 +03:30
parent 9dc1728edc
commit cefd42de17
35 changed files with 1467 additions and 11 deletions
@@ -0,0 +1,29 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
@Entity('short_links')
export class ShortLink {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ length: 12 })
@Index({ unique: true })
code: string;
@Column({ type: 'text' })
targetUrl: string;
@Column({ type: 'int', default: 0 })
clickCount: number;
@Column({ type: 'timestamp', nullable: true })
expiresAt: Date | null;
@CreateDateColumn()
createdAt: Date;
}
+22
View File
@@ -0,0 +1,22 @@
import { Controller, Get, Param, Res } from '@nestjs/common';
import { ApiExcludeController } from '@nestjs/swagger';
import type { Response } from 'express';
import { Public } from '../auth/decorators/public.decorator';
import { ShortLinksService } from './short-links.service';
/**
* Registered outside the global `api` prefix (see main.ts) so links stay short,
* e.g. `https://api.example.com/s/aB3xQ9k`.
*/
@ApiExcludeController()
@Controller('s')
export class ShortLinksController {
constructor(private readonly shortLinksService: ShortLinksService) {}
@Public()
@Get(':code')
async redirect(@Param('code') code: string, @Res() res: Response) {
const targetUrl = await this.shortLinksService.resolveAndTrack(code);
return res.redirect(targetUrl);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ShortLink } from './entities/short-link.entity';
import { ShortLinksController } from './short-links.controller';
import { ShortLinksService } from './short-links.service';
@Module({
imports: [TypeOrmModule.forFeature([ShortLink])],
controllers: [ShortLinksController],
providers: [ShortLinksService],
exports: [ShortLinksService],
})
export class ShortLinksModule {}
+77
View File
@@ -0,0 +1,77 @@
import { GoneException, Injectable, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { randomBytes } from 'crypto';
import { Repository } from 'typeorm';
import { ShortLink } from './entities/short-link.entity';
const CODE_ALPHABET =
'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 7;
const MAX_GENERATION_ATTEMPTS = 5;
@Injectable()
export class ShortLinksService {
private readonly apiBaseUrl: string;
constructor(
@InjectRepository(ShortLink)
private readonly repository: Repository<ShortLink>,
private readonly configService: ConfigService,
) {
this.apiBaseUrl = (
this.configService.get<string>('API_PUBLIC_URL') ??
'http://localhost:4000'
).replace(/\/$/, '');
}
/** Creates a short link and returns its public, shareable URL. */
async createShortUrl(
targetUrl: string,
expiresAt: Date | null = null,
): Promise<string> {
const code = await this.generateUniqueCode();
await this.repository.save(
this.repository.create({ code, targetUrl, expiresAt }),
);
return `${this.apiBaseUrl}/s/${code}`;
}
async resolveAndTrack(code: string): Promise<string> {
const shortLink = await this.repository.findOne({ where: { code } });
if (!shortLink) {
throw new NotFoundException('لینک یافت نشد');
}
if (shortLink.expiresAt && shortLink.expiresAt.getTime() < Date.now()) {
throw new GoneException('این لینک منقضی شده است');
}
shortLink.clickCount += 1;
await this.repository.save(shortLink);
return shortLink.targetUrl;
}
private async generateUniqueCode(): Promise<string> {
for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) {
const code = this.generateCode();
const exists = await this.repository.existsBy({ code });
if (!exists) {
return code;
}
}
throw new Error('Failed to generate a unique short link code');
}
private generateCode(): string {
const bytes = randomBytes(CODE_LENGTH);
let code = '';
for (let i = 0; i < CODE_LENGTH; i += 1) {
code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length];
}
return code;
}
}