-
-
Notifications
You must be signed in to change notification settings - Fork 117
/
urls.service.ts
142 lines (123 loc) · 3.84 KB
/
urls.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import { sample } from '@jonahsnider/util';
import { Inject, Injectable, InternalServerErrorException, UnprocessableEntityException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DatabaseError } from 'pg';
import { BlockedUrlsService } from '../blocked-urls/blocked-urls.service';
import { ConfigService } from '../config/config.service';
import { Schema } from '../db/index';
import type { Db } from '../db/interfaces/db.interface';
import { DB_PROVIDER } from '../db/providers';
import type { Short } from './dtos/short.dto';
import type { ShortenedUrlData } from './interfaces/shortened-url.interface';
import type { Base64 } from './interfaces/urls.interface';
import type { VisitUrlData } from './interfaces/visit-url-data.interface';
@Injectable()
export class UrlsService {
/** Maximum number of attempts to generate a unique ID. */
private static readonly MAX_SHORT_ID_GENERATION_ATTEMPTS = 10;
static toBase64(value: string): Base64 {
return Buffer.from(value).toString('base64') as Base64;
}
constructor(
@Inject(BlockedUrlsService) private readonly blockedUrlsService: BlockedUrlsService,
@Inject(ConfigService) private readonly configService: ConfigService,
@Inject(DB_PROVIDER) private readonly db: Db,
) {}
/**
* Retrieve a shortened URL.
*
* @param id - The ID of the shortened URL to visit
*
* @returns The long URL and whether it was blocked
*/
async retrieveUrl(id: Short): Promise<VisitUrlData | undefined> {
const encodedId = UrlsService.toBase64(id);
const [shortenedUrl] = await this.db
.select({
url: Schema.urls.url,
blocked: Schema.urls.blocked,
})
.from(Schema.urls)
.where(eq(Schema.urls.shortBase64, encodedId));
if (!shortenedUrl) {
return undefined;
}
if (await this.blockedUrlsService.isUrlBlocked(new URL(shortenedUrl.url))) {
if (!shortenedUrl.blocked) {
// The URL hostname is blocked, but the entry in the database isn't
// So, we should update the entry in the DB to mark it as blocked
await this.db.update(Schema.urls).set({ blocked: true }).where(eq(Schema.urls.shortBase64, encodedId));
}
return {
longUrl: undefined,
blocked: true,
};
}
return {
longUrl: shortenedUrl.url,
blocked: false,
};
}
/**
* Shorten a long URL.
*
* @param url - The long URL to shorten
*
* @returns The ID of the shortened URL
*/
async shortenUrl(url: string): Promise<ShortenedUrlData> {
const urlObject = new URL(url);
const matches = this.blockedUrlsService.matchesCaptchaPhishHeuristic(urlObject);
if (await this.blockedUrlsService.isUrlBlocked(urlObject)) {
if (!matches) {
throw new UnprocessableEntityException('That URL hostname is blocked');
}
}
let attempts = 0;
let created: typeof Schema.urls.$inferSelect | undefined;
let id: Short;
do {
if (attempts++ > UrlsService.MAX_SHORT_ID_GENERATION_ATTEMPTS) {
throw new InternalServerErrorException(
'Unable to generate a unique short ID within the max number of attempts',
);
}
id = this.generateShortId();
const shortBase64 = UrlsService.toBase64(id);
try {
[created] = await this.db
.insert(Schema.urls)
.values({
url,
shortBase64,
createdAt: new Date(),
blocked: matches,
})
.returning();
} catch (error) {
if (error instanceof DatabaseError && error.code === '23505') {
// Ignore the expected potential duplicate ID errors
} else {
throw error;
}
}
} while (!created);
return {
short: id,
url: new URL(id, this.configService.websiteUrl),
};
}
/**
* Generate a short ID.
* Not guaranteed to be unique.
*
* @returns A short ID
*/
private generateShortId(): Short {
let shortId = '';
for (let i = 0; i < this.configService.shortenedLength; i++) {
shortId += sample(this.configService.characters);
}
return shortId as Short;
}
}