mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 06:26:41 +02:00
Feat: New project chat, uload refactor (postgress), hosting plans, uload landingpage
This commit is contained in:
parent
559eb08d8c
commit
fcf3a344b1
123 changed files with 7106 additions and 3715 deletions
22
uload/apps/backend/.env.example
Normal file
22
uload/apps/backend/.env.example
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Server
|
||||
NODE_ENV=development
|
||||
PORT=3003
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/uload
|
||||
|
||||
# Redis (for caching)
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Mana Core Auth
|
||||
MANA_SERVICE_URL=https://mana-core-middleware-111768794939.europe-west3.run.app
|
||||
APP_ID=your-uload-app-id
|
||||
MANA_SERVICE_KEY=
|
||||
|
||||
# Frontend URL (for CORS)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Short URL base (for generating short links)
|
||||
SHORT_URL_BASE=https://ulo.ad
|
||||
65
uload/apps/backend/Dockerfile
Normal file
65
uload/apps/backend/Dockerfile
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
|
||||
# Copy workspace packages
|
||||
COPY packages/uload-database ./packages/uload-database
|
||||
|
||||
# Copy backend source
|
||||
COPY uload/apps/backend ./uload/apps/backend
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build the database package first
|
||||
WORKDIR /app/packages/uload-database
|
||||
RUN pnpm build
|
||||
|
||||
# Build the backend
|
||||
WORKDIR /app/uload/apps/backend
|
||||
RUN pnpm build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Install dumb-init for proper signal handling
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nestjs
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built artifacts
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/dist ./dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/package.json ./
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/uload/apps/backend/node_modules ./node_modules
|
||||
|
||||
# Copy database package (needed at runtime)
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/dist ./node_modules/@manacore/uload-database/dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/packages/uload-database/package.json ./node_modules/@manacore/uload-database/
|
||||
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3003
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3003/health || exit 1
|
||||
|
||||
# Set environment
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3003
|
||||
|
||||
# Start with dumb-init
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["node", "dist/main"]
|
||||
8
uload/apps/backend/nest-cli.json
Normal file
8
uload/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
71
uload/apps/backend/package.json
Normal file
71
uload/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"name": "@uload/backend",
|
||||
"version": "0.0.1",
|
||||
"description": "ULOAD URL Shortener Backend",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mana-core/nestjs-integration": "git+https://github.com/Memo-2023/mana-core-nestjs-package.git",
|
||||
"@manacore/uload-database": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"axios": "^1.7.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"ioredis": "^5.4.1",
|
||||
"joi": "^18.0.1",
|
||||
"nanoid": "^5.0.7",
|
||||
"nestjs-cls": "^6.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"ua-parser-js": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@types/ua-parser-js": "^0.7.39",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
80
uload/apps/backend/src/app.module.ts
Normal file
80
uload/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ManaCoreModule } from '@mana-core/nestjs-integration';
|
||||
|
||||
import { validationSchema } from './config/validation.schema';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { LinkRepository } from './database/repositories/link.repository';
|
||||
import { ClickRepository } from './database/repositories/click.repository';
|
||||
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
import { RedirectController } from './controllers/redirect.controller';
|
||||
import { LinksController } from './controllers/links.controller';
|
||||
import { AnalyticsController } from './controllers/analytics.controller';
|
||||
|
||||
import { LinksService } from './services/links.service';
|
||||
import { RedirectService } from './services/redirect.service';
|
||||
import { AnalyticsService } from './services/analytics.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Context-Local Storage for request-scoped data
|
||||
ClsModule.forRoot({
|
||||
global: true,
|
||||
middleware: { mount: true, generateId: true },
|
||||
}),
|
||||
|
||||
// Configuration
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
validationSchema,
|
||||
validationOptions: {
|
||||
allowUnknown: true,
|
||||
abortEarly: false,
|
||||
},
|
||||
ignoreEnvFile: process.env.NODE_ENV === 'production',
|
||||
}),
|
||||
|
||||
// Mana Core Authentication
|
||||
ManaCoreModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
manaServiceUrl: configService.get<string>('MANA_SERVICE_URL')!,
|
||||
appId: configService.get<string>('APP_ID')!,
|
||||
serviceKey: configService.get<string>('MANA_SERVICE_KEY', ''),
|
||||
debug: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}) as any,
|
||||
|
||||
// Health checks
|
||||
TerminusModule,
|
||||
HttpModule,
|
||||
|
||||
// Database
|
||||
DatabaseModule,
|
||||
],
|
||||
controllers: [
|
||||
HealthController,
|
||||
RedirectController,
|
||||
LinksController,
|
||||
AnalyticsController,
|
||||
],
|
||||
providers: [
|
||||
// Repositories
|
||||
LinkRepository,
|
||||
ClickRepository,
|
||||
// Services
|
||||
LinksService,
|
||||
RedirectService,
|
||||
AnalyticsService,
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
// Add custom middleware here if needed
|
||||
}
|
||||
}
|
||||
28
uload/apps/backend/src/config/validation.schema.ts
Normal file
28
uload/apps/backend/src/config/validation.schema.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import * as Joi from 'joi';
|
||||
|
||||
export const validationSchema = Joi.object({
|
||||
// Server
|
||||
NODE_ENV: Joi.string()
|
||||
.valid('development', 'production', 'test')
|
||||
.default('development'),
|
||||
PORT: Joi.number().default(3003),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: Joi.string().uri().required(),
|
||||
|
||||
// Redis
|
||||
REDIS_HOST: Joi.string().default('localhost'),
|
||||
REDIS_PORT: Joi.number().default(6379),
|
||||
REDIS_PASSWORD: Joi.string().allow('').optional(),
|
||||
|
||||
// Mana Core Auth
|
||||
MANA_SERVICE_URL: Joi.string().uri().required(),
|
||||
APP_ID: Joi.string().uuid().required(),
|
||||
MANA_SERVICE_KEY: Joi.string().allow('').optional(),
|
||||
|
||||
// Frontend
|
||||
FRONTEND_URL: Joi.string().uri().optional(),
|
||||
|
||||
// Short URL
|
||||
SHORT_URL_BASE: Joi.string().uri().default('https://ulo.ad'),
|
||||
});
|
||||
98
uload/apps/backend/src/controllers/analytics.controller.ts
Normal file
98
uload/apps/backend/src/controllers/analytics.controller.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
import { LinksService } from '../services/links.service';
|
||||
|
||||
@Controller('api/analytics')
|
||||
@UseGuards(AuthGuard)
|
||||
export class AnalyticsController {
|
||||
constructor(
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly linksService: LinksService,
|
||||
) {}
|
||||
|
||||
@Get('links/:linkId')
|
||||
async getLinkAnalytics(
|
||||
@CurrentUser() user: any,
|
||||
@Param('linkId') linkId: string,
|
||||
@Query('from') fromDate?: string,
|
||||
@Query('to') toDate?: string,
|
||||
) {
|
||||
const userId = user.sub;
|
||||
|
||||
// Verify user owns the link
|
||||
const link = await this.linksService.getLinkById(linkId, userId);
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
const stats = await this.analyticsService.getStats(
|
||||
linkId,
|
||||
fromDate ? new Date(fromDate) : undefined,
|
||||
toDate ? new Date(toDate) : undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
linkId,
|
||||
shortCode: link.shortCode,
|
||||
stats,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('links/:linkId/clicks')
|
||||
async getLinkClicks(
|
||||
@CurrentUser() user: any,
|
||||
@Param('linkId') linkId: string,
|
||||
@Query('limit') limit: number = 100,
|
||||
) {
|
||||
const userId = user.sub;
|
||||
|
||||
// Verify user owns the link
|
||||
const link = await this.linksService.getLinkById(linkId, userId);
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
const { clicks, total } = await this.analyticsService.getRecentClicks(
|
||||
linkId,
|
||||
limit,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
linkId,
|
||||
clicks: clicks.map((click) => ({
|
||||
...click,
|
||||
ipHash: undefined, // Don't expose IP hash
|
||||
})),
|
||||
total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('overview')
|
||||
async getOverview(@CurrentUser() user: any) {
|
||||
const userId = user.sub;
|
||||
const totalLinks = await this.linksService.getLinkCount(userId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalLinks,
|
||||
// Add more overview stats as needed
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
35
uload/apps/backend/src/controllers/health.controller.ts
Normal file
35
uload/apps/backend/src/controllers/health.controller.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import {
|
||||
HealthCheckService,
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
} from '@nestjs/terminus';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private health: HealthCheckService) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check(): Promise<HealthCheckResult> {
|
||||
return this.health.check([]);
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
ready() {
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
live() {
|
||||
return {
|
||||
status: 'live',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
};
|
||||
}
|
||||
}
|
||||
131
uload/apps/backend/src/controllers/links.controller.ts
Normal file
131
uload/apps/backend/src/controllers/links.controller.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard, CurrentUser } from '@mana-core/nestjs-integration';
|
||||
import { LinksService, type CreateLinkDto, type UpdateLinkDto } from '../services/links.service';
|
||||
|
||||
@Controller('api/links')
|
||||
@UseGuards(AuthGuard)
|
||||
export class LinksController {
|
||||
constructor(private readonly linksService: LinksService) {}
|
||||
|
||||
@Get()
|
||||
async getLinks(
|
||||
@CurrentUser() user: any,
|
||||
@Query('page') page: number = 1,
|
||||
@Query('limit') limit: number = 20,
|
||||
@Query('search') search?: string,
|
||||
@Query('isActive') isActive?: boolean,
|
||||
) {
|
||||
const userId = user.sub;
|
||||
const { items, total } = await this.linksService.getLinks(userId, {
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
isActive,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
links: items.map((link) => ({
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined, // Never send password to client
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
hasMore: page * limit < total,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getLink(@CurrentUser() user: any, @Param('id') id: string) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.getLinkById(id, userId);
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createLink(@CurrentUser() user: any, @Body() dto: CreateLinkDto) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.createLink(userId, dto);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
async updateLink(
|
||||
@CurrentUser() user: any,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateLinkDto,
|
||||
) {
|
||||
const userId = user.sub;
|
||||
const link = await this.linksService.updateLink(id, userId, dto);
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...link,
|
||||
shortUrl: this.linksService.getShortUrl(link.shortCode),
|
||||
hasPassword: !!link.password,
|
||||
password: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteLink(@CurrentUser() user: any, @Param('id') id: string) {
|
||||
const userId = user.sub;
|
||||
const deleted = await this.linksService.deleteLink(id, userId);
|
||||
|
||||
if (!deleted) {
|
||||
throw new NotFoundException('Link not found');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Link deleted successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
113
uload/apps/backend/src/controllers/redirect.controller.ts
Normal file
113
uload/apps/backend/src/controllers/redirect.controller.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Body,
|
||||
Req,
|
||||
Res,
|
||||
HttpStatus,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { Response, Request } from 'express';
|
||||
import { RedirectService } from '../services/redirect.service';
|
||||
import { AnalyticsService } from '../services/analytics.service';
|
||||
|
||||
@Controller()
|
||||
export class RedirectController {
|
||||
constructor(
|
||||
private readonly redirectService: RedirectService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
) {}
|
||||
|
||||
@Get(':code')
|
||||
async redirect(
|
||||
@Param('code') code: string,
|
||||
@Query('utm_source') utmSource: string,
|
||||
@Query('utm_medium') utmMedium: string,
|
||||
@Query('utm_campaign') utmCampaign: string,
|
||||
@Req() request: Request,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
// Skip for API and health routes
|
||||
if (code === 'v1' || code === 'health') {
|
||||
return response.status(HttpStatus.NOT_FOUND).json({
|
||||
success: false,
|
||||
error: 'not_found',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.redirectService.getRedirect(code);
|
||||
|
||||
if (!result.success) {
|
||||
switch (result.error) {
|
||||
case 'not_found':
|
||||
return response.status(HttpStatus.NOT_FOUND).json({
|
||||
success: false,
|
||||
error: 'Link not found',
|
||||
});
|
||||
|
||||
case 'expired':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link has expired',
|
||||
});
|
||||
|
||||
case 'inactive':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link is no longer active',
|
||||
});
|
||||
|
||||
case 'max_clicks':
|
||||
return response.status(HttpStatus.GONE).json({
|
||||
success: false,
|
||||
error: 'This link has reached its maximum clicks',
|
||||
});
|
||||
|
||||
case 'password_required':
|
||||
return response.status(HttpStatus.OK).json({
|
||||
success: false,
|
||||
passwordRequired: true,
|
||||
linkId: result.linkId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Record click asynchronously (don't wait)
|
||||
this.analyticsService
|
||||
.recordClick(result.linkId!, {
|
||||
userAgent: request.headers['user-agent'] || '',
|
||||
referer: request.headers['referer'] || '',
|
||||
ip: request.ip,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
})
|
||||
.catch((err) => console.error('Failed to record click:', err));
|
||||
|
||||
// Perform redirect
|
||||
return response.redirect(302, result.targetUrl!);
|
||||
}
|
||||
|
||||
@Post(':code/unlock')
|
||||
async unlockLink(
|
||||
@Param('code') code: string,
|
||||
@Body('password') password: string,
|
||||
@Res() response: Response,
|
||||
) {
|
||||
const result = await this.redirectService.verifyPassword(code, password);
|
||||
|
||||
if (!result.success) {
|
||||
return response.status(HttpStatus.UNAUTHORIZED).json({
|
||||
success: false,
|
||||
error: 'Invalid password',
|
||||
});
|
||||
}
|
||||
|
||||
return response.json({
|
||||
success: true,
|
||||
targetUrl: result.targetUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
29
uload/apps/backend/src/database/database.module.ts
Normal file
29
uload/apps/backend/src/database/database.module.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Module, Global, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import { getDb, closeDb, type Database } from '@manacore/uload-database';
|
||||
|
||||
export const DATABASE_TOKEN = 'DATABASE';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: () => {
|
||||
const logger = new Logger('DatabaseModule');
|
||||
logger.log('Initializing database connection');
|
||||
return getDb();
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(DatabaseModule.name);
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('Closing database connection');
|
||||
await closeDb();
|
||||
}
|
||||
}
|
||||
|
||||
export type { Database };
|
||||
162
uload/apps/backend/src/database/repositories/click.repository.ts
Normal file
162
uload/apps/backend/src/database/repositories/click.repository.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
clicks,
|
||||
type Click,
|
||||
type NewClick,
|
||||
eq,
|
||||
desc,
|
||||
sql,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
} from '@manacore/uload-database';
|
||||
|
||||
export interface ClickStats {
|
||||
totalClicks: number;
|
||||
uniqueVisitors: number;
|
||||
topCountries: { country: string; count: number }[];
|
||||
topBrowsers: { browser: string; count: number }[];
|
||||
topDevices: { deviceType: string; count: number }[];
|
||||
clicksByDay: { date: string; count: number }[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClickRepository {
|
||||
private readonly logger = new Logger(ClickRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async create(data: NewClick): Promise<Click> {
|
||||
const result = await this.db.insert(clicks).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async findByLinkId(
|
||||
linkId: string,
|
||||
options: { limit?: number; offset?: number } = {},
|
||||
): Promise<Click[]> {
|
||||
const { limit = 100, offset = 0 } = options;
|
||||
return this.db
|
||||
.select()
|
||||
.from(clicks)
|
||||
.where(eq(clicks.linkId, linkId))
|
||||
.orderBy(desc(clicks.clickedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async countByLinkId(linkId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clicks)
|
||||
.where(eq(clicks.linkId, linkId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
async getStats(
|
||||
linkId: string,
|
||||
fromDate?: Date,
|
||||
toDate?: Date,
|
||||
): Promise<ClickStats> {
|
||||
const conditions = [eq(clicks.linkId, linkId)];
|
||||
|
||||
if (fromDate) {
|
||||
conditions.push(gte(clicks.clickedAt, fromDate));
|
||||
}
|
||||
if (toDate) {
|
||||
conditions.push(lte(clicks.clickedAt, toDate));
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
// Total clicks
|
||||
const totalResult = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(clicks)
|
||||
.where(whereClause);
|
||||
|
||||
// Unique visitors (by IP hash)
|
||||
const uniqueResult = await this.db
|
||||
.select({ count: sql<number>`count(distinct ${clicks.ipHash})::int` })
|
||||
.from(clicks)
|
||||
.where(whereClause);
|
||||
|
||||
// Top countries
|
||||
const countriesResult = await this.db
|
||||
.select({
|
||||
country: clicks.country,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.country)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Top browsers
|
||||
const browsersResult = await this.db
|
||||
.select({
|
||||
browser: clicks.browser,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.browser)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Top devices
|
||||
const devicesResult = await this.db
|
||||
.select({
|
||||
deviceType: clicks.deviceType,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(clicks.deviceType)
|
||||
.orderBy(sql`count(*) desc`)
|
||||
.limit(10);
|
||||
|
||||
// Clicks by day (last 30 days)
|
||||
const clicksByDayResult = await this.db
|
||||
.select({
|
||||
date: sql<string>`date_trunc('day', ${clicks.clickedAt})::date::text`,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(clicks)
|
||||
.where(whereClause)
|
||||
.groupBy(sql`date_trunc('day', ${clicks.clickedAt})`)
|
||||
.orderBy(sql`date_trunc('day', ${clicks.clickedAt})`)
|
||||
.limit(30);
|
||||
|
||||
return {
|
||||
totalClicks: totalResult[0]?.count || 0,
|
||||
uniqueVisitors: uniqueResult[0]?.count || 0,
|
||||
topCountries: countriesResult.map((r) => ({
|
||||
country: r.country || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
topBrowsers: browsersResult.map((r) => ({
|
||||
browser: r.browser || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
topDevices: devicesResult.map((r) => ({
|
||||
deviceType: r.deviceType || 'Unknown',
|
||||
count: r.count,
|
||||
})),
|
||||
clicksByDay: clicksByDayResult.map((r) => ({
|
||||
date: r.date,
|
||||
count: r.count,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteByLinkId(linkId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.delete(clicks)
|
||||
.where(eq(clicks.linkId, linkId))
|
||||
.returning({ id: clicks.id });
|
||||
return result.length;
|
||||
}
|
||||
}
|
||||
2
uload/apps/backend/src/database/repositories/index.ts
Normal file
2
uload/apps/backend/src/database/repositories/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { LinkRepository, type ListLinksOptions } from './link.repository';
|
||||
export { ClickRepository, type ClickStats } from './click.repository';
|
||||
148
uload/apps/backend/src/database/repositories/link.repository.ts
Normal file
148
uload/apps/backend/src/database/repositories/link.repository.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { DATABASE_TOKEN, type Database } from '../database.module';
|
||||
import {
|
||||
links,
|
||||
type Link,
|
||||
type NewLink,
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
sql,
|
||||
or,
|
||||
ilike,
|
||||
} from '@manacore/uload-database';
|
||||
|
||||
export interface ListLinksOptions {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinkRepository {
|
||||
private readonly logger = new Logger(LinkRepository.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
async findByShortCode(shortCode: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(eq(links.shortCode, shortCode))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(eq(links.id, id))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIdAndUserId(id: string, userId: string): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
options: ListLinksOptions = {},
|
||||
): Promise<{ items: Link[]; total: number }> {
|
||||
const { page = 1, limit = 20, search, isActive } = options;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [eq(links.userId, userId)];
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(links.title, `%${search}%`),
|
||||
ilike(links.originalUrl, `%${search}%`),
|
||||
ilike(links.shortCode, `%${search}%`),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
if (isActive !== undefined) {
|
||||
conditions.push(eq(links.isActive, isActive));
|
||||
}
|
||||
|
||||
const [countResult, items] = await Promise.all([
|
||||
this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(links)
|
||||
.where(and(...conditions)),
|
||||
this.db
|
||||
.select()
|
||||
.from(links)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(links.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: countResult[0]?.count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async create(data: NewLink): Promise<Link> {
|
||||
this.logger.debug(`Creating link: ${data.shortCode}`);
|
||||
const result = await this.db.insert(links).values(data).returning();
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
userId: string,
|
||||
data: Partial<Omit<NewLink, 'id' | 'userId' | 'createdAt'>>,
|
||||
): Promise<Link | null> {
|
||||
const result = await this.db
|
||||
.update(links)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.returning();
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(links)
|
||||
.where(and(eq(links.id, id), eq(links.userId, userId)))
|
||||
.returning({ id: links.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async incrementClickCount(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(links)
|
||||
.set({ clickCount: sql`${links.clickCount} + 1` })
|
||||
.where(eq(links.id, id));
|
||||
}
|
||||
|
||||
async isShortCodeAvailable(shortCode: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select({ id: links.id })
|
||||
.from(links)
|
||||
.where(eq(links.shortCode, shortCode))
|
||||
.limit(1);
|
||||
return result.length === 0;
|
||||
}
|
||||
|
||||
async countByUserId(userId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(links)
|
||||
.where(eq(links.userId, userId));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
}
|
||||
47
uload/apps/backend/src/main.ts
Normal file
47
uload/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
// CORS configuration
|
||||
app.enableCors({
|
||||
origin: configService.get('FRONTEND_URL') || true,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
});
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Global prefix for API routes (except health and redirect)
|
||||
app.setGlobalPrefix('v1', {
|
||||
exclude: ['health', 'health/(.*)', ':code'],
|
||||
});
|
||||
|
||||
const port = configService.get('PORT') || 3003;
|
||||
|
||||
await app.listen(port);
|
||||
logger.log(`ULOAD Backend running on port ${port}`);
|
||||
logger.log(`Health check: http://localhost:${port}/health`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
102
uload/apps/backend/src/services/analytics.service.ts
Normal file
102
uload/apps/backend/src/services/analytics.service.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as UAParser from 'ua-parser-js';
|
||||
import { ClickRepository, type ClickStats } from '../database/repositories';
|
||||
import { RedirectService } from './redirect.service';
|
||||
import type { NewClick } from '@manacore/uload-database';
|
||||
|
||||
export interface RecordClickData {
|
||||
userAgent: string;
|
||||
referer?: string;
|
||||
ip?: string;
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly clickRepository: ClickRepository,
|
||||
private readonly redirectService: RedirectService,
|
||||
) {}
|
||||
|
||||
async recordClick(linkId: string, data: RecordClickData): Promise<void> {
|
||||
try {
|
||||
// Parse user agent
|
||||
const parser = new UAParser.UAParser(data.userAgent);
|
||||
const browser = parser.getBrowser();
|
||||
const os = parser.getOS();
|
||||
const device = parser.getDevice();
|
||||
|
||||
// Hash IP for privacy
|
||||
const ipHash = data.ip ? this.hashIp(data.ip) : null;
|
||||
|
||||
// Determine device type
|
||||
let deviceType = 'desktop';
|
||||
if (device.type === 'mobile') {
|
||||
deviceType = 'mobile';
|
||||
} else if (device.type === 'tablet') {
|
||||
deviceType = 'tablet';
|
||||
}
|
||||
|
||||
const clickData: NewClick = {
|
||||
linkId,
|
||||
ipHash,
|
||||
userAgent: data.userAgent,
|
||||
referer: data.referer,
|
||||
browser: browser.name || 'Unknown',
|
||||
deviceType,
|
||||
os: os.name || 'Unknown',
|
||||
// TODO: Geo lookup from IP
|
||||
country: null,
|
||||
city: null,
|
||||
utmSource: data.utmSource,
|
||||
utmMedium: data.utmMedium,
|
||||
utmCampaign: data.utmCampaign,
|
||||
};
|
||||
|
||||
await this.clickRepository.create(clickData);
|
||||
|
||||
// Increment click count on the link
|
||||
await this.redirectService.incrementClickCount(linkId);
|
||||
|
||||
this.logger.debug(`Recorded click for link ${linkId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to record click for link ${linkId}:`, error);
|
||||
// Don't throw - click recording should not block redirect
|
||||
}
|
||||
}
|
||||
|
||||
async getStats(
|
||||
linkId: string,
|
||||
fromDate?: Date,
|
||||
toDate?: Date,
|
||||
): Promise<ClickStats> {
|
||||
return this.clickRepository.getStats(linkId, fromDate, toDate);
|
||||
}
|
||||
|
||||
async getRecentClicks(
|
||||
linkId: string,
|
||||
limit: number = 100,
|
||||
): Promise<{ clicks: any[]; total: number }> {
|
||||
const [clicks, total] = await Promise.all([
|
||||
this.clickRepository.findByLinkId(linkId, { limit }),
|
||||
this.clickRepository.countByLinkId(linkId),
|
||||
]);
|
||||
|
||||
return { clicks, total };
|
||||
}
|
||||
|
||||
private hashIp(ip: string): string {
|
||||
// Simple hash for privacy - in production use a proper hash function
|
||||
let hash = 0;
|
||||
for (let i = 0; i < ip.length; i++) {
|
||||
const char = ip.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
}
|
||||
144
uload/apps/backend/src/services/links.service.ts
Normal file
144
uload/apps/backend/src/services/links.service.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { LinkRepository, type ListLinksOptions } from '../database/repositories';
|
||||
import type { Link, NewLink } from '@manacore/uload-database';
|
||||
|
||||
export interface CreateLinkDto {
|
||||
originalUrl: string;
|
||||
customCode?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
password?: string;
|
||||
maxClicks?: number;
|
||||
expiresAt?: Date;
|
||||
tags?: string[];
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateLinkDto {
|
||||
title?: string;
|
||||
description?: string;
|
||||
password?: string;
|
||||
maxClicks?: number;
|
||||
expiresAt?: Date;
|
||||
isActive?: boolean;
|
||||
tags?: string[];
|
||||
utmSource?: string;
|
||||
utmMedium?: string;
|
||||
utmCampaign?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinksService {
|
||||
private readonly logger = new Logger(LinksService.name);
|
||||
private readonly shortUrlBase: string;
|
||||
|
||||
constructor(
|
||||
private readonly linkRepository: LinkRepository,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.shortUrlBase = this.configService.get('SHORT_URL_BASE', 'https://ulo.ad');
|
||||
}
|
||||
|
||||
async createLink(userId: string, dto: CreateLinkDto): Promise<Link> {
|
||||
// Generate or validate short code
|
||||
let shortCode = dto.customCode;
|
||||
|
||||
if (shortCode) {
|
||||
// Validate custom code format
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(shortCode)) {
|
||||
throw new BadRequestException(
|
||||
'Custom code can only contain letters, numbers, hyphens and underscores',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if custom code is available
|
||||
const isAvailable = await this.linkRepository.isShortCodeAvailable(shortCode);
|
||||
if (!isAvailable) {
|
||||
throw new BadRequestException('This custom code is already taken');
|
||||
}
|
||||
} else {
|
||||
// Generate random short code
|
||||
shortCode = nanoid(7);
|
||||
|
||||
// Make sure it's unique (very unlikely to collide, but check anyway)
|
||||
let attempts = 0;
|
||||
while (
|
||||
!(await this.linkRepository.isShortCodeAvailable(shortCode)) &&
|
||||
attempts < 5
|
||||
) {
|
||||
shortCode = nanoid(7);
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
|
||||
const newLink: NewLink = {
|
||||
shortCode,
|
||||
customCode: dto.customCode,
|
||||
originalUrl: dto.originalUrl,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
userId,
|
||||
password: dto.password, // TODO: Hash password if provided
|
||||
maxClicks: dto.maxClicks,
|
||||
expiresAt: dto.expiresAt,
|
||||
tags: dto.tags,
|
||||
utmSource: dto.utmSource,
|
||||
utmMedium: dto.utmMedium,
|
||||
utmCampaign: dto.utmCampaign,
|
||||
workspaceId: dto.workspaceId,
|
||||
};
|
||||
|
||||
const link = await this.linkRepository.create(newLink);
|
||||
this.logger.log(`Created link ${link.shortCode} for user ${userId}`);
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
async updateLink(
|
||||
id: string,
|
||||
userId: string,
|
||||
dto: UpdateLinkDto,
|
||||
): Promise<Link | null> {
|
||||
const link = await this.linkRepository.update(id, userId, dto);
|
||||
|
||||
if (link) {
|
||||
this.logger.log(`Updated link ${link.shortCode} for user ${userId}`);
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
async deleteLink(id: string, userId: string): Promise<boolean> {
|
||||
const deleted = await this.linkRepository.delete(id, userId);
|
||||
|
||||
if (deleted) {
|
||||
this.logger.log(`Deleted link ${id} for user ${userId}`);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async getLinkById(id: string, userId: string): Promise<Link | null> {
|
||||
return this.linkRepository.findByIdAndUserId(id, userId);
|
||||
}
|
||||
|
||||
async getLinks(
|
||||
userId: string,
|
||||
options: ListLinksOptions,
|
||||
): Promise<{ items: Link[]; total: number }> {
|
||||
return this.linkRepository.findByUserId(userId, options);
|
||||
}
|
||||
|
||||
async getLinkCount(userId: string): Promise<number> {
|
||||
return this.linkRepository.countByUserId(userId);
|
||||
}
|
||||
|
||||
getShortUrl(shortCode: string): string {
|
||||
return `${this.shortUrlBase}/${shortCode}`;
|
||||
}
|
||||
}
|
||||
77
uload/apps/backend/src/services/redirect.service.ts
Normal file
77
uload/apps/backend/src/services/redirect.service.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { LinkRepository } from '../database/repositories';
|
||||
import type { Link } from '@manacore/uload-database';
|
||||
|
||||
export interface RedirectResult {
|
||||
success: boolean;
|
||||
targetUrl?: string;
|
||||
linkId?: string;
|
||||
error?: 'not_found' | 'expired' | 'inactive' | 'max_clicks' | 'password_required';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedirectService {
|
||||
private readonly logger = new Logger(RedirectService.name);
|
||||
|
||||
constructor(private readonly linkRepository: LinkRepository) {}
|
||||
|
||||
async getRedirect(shortCode: string): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
|
||||
// Check if link is active
|
||||
if (!link.isActive) {
|
||||
return { success: false, error: 'inactive', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check if link has expired
|
||||
if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
|
||||
return { success: false, error: 'expired', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check max clicks
|
||||
if (link.maxClicks && (link.clickCount ?? 0) >= link.maxClicks) {
|
||||
return { success: false, error: 'max_clicks', linkId: link.id };
|
||||
}
|
||||
|
||||
// Check if password protected
|
||||
if (link.password) {
|
||||
return { success: false, error: 'password_required', linkId: link.id };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyPassword(
|
||||
shortCode: string,
|
||||
password: string,
|
||||
): Promise<RedirectResult> {
|
||||
const link = await this.linkRepository.findByShortCode(shortCode);
|
||||
|
||||
if (!link) {
|
||||
return { success: false, error: 'not_found' };
|
||||
}
|
||||
|
||||
// TODO: Compare hashed passwords
|
||||
if (link.password !== password) {
|
||||
return { success: false, error: 'password_required', linkId: link.id };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
targetUrl: link.originalUrl,
|
||||
linkId: link.id,
|
||||
};
|
||||
}
|
||||
|
||||
async incrementClickCount(linkId: string): Promise<void> {
|
||||
await this.linkRepository.incrementClickCount(linkId);
|
||||
}
|
||||
}
|
||||
23
uload/apps/backend/tsconfig.json
Normal file
23
uload/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue