feat: add link generation support (short/sentence links) + wordlist
All checks were successful
Update changelog / changelog (push) Successful in 26s
All checks were successful
Update changelog / changelog (push) Successful in 26s
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,3 +8,6 @@ temp/
|
||||
|
||||
# .env
|
||||
.env
|
||||
|
||||
# wordlist
|
||||
src/tools/wordlist.ts
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AppDataSource } from './data-source'
|
||||
import inferUser from './middleware/inferUser';
|
||||
import miscRouter from './routes/miscRoutes';
|
||||
import userRouter from './routes/userRoutes';
|
||||
import linkRouter from './routes/linkRoutes';
|
||||
import { getCorsConfig } from './tools/cors';
|
||||
|
||||
AppDataSource.initialize().then(async () => {
|
||||
@@ -18,7 +19,7 @@ AppDataSource.initialize().then(async () => {
|
||||
app.use(express.json());
|
||||
app.use(getCorsConfig());
|
||||
app.use(inferUser);
|
||||
app.use(miscRouter, userRouter);
|
||||
app.use(miscRouter, userRouter, linkRouter);
|
||||
|
||||
if (process.env['DEBUG'] === 'true') {
|
||||
const swaggerJsdocOpts = {
|
||||
|
||||
@@ -7,6 +7,8 @@ import { generateSha512 } from '../tools/hasher';
|
||||
import * as jwt from '../tools/jwt';
|
||||
|
||||
/**
|
||||
* `POST /api/v1/user/signIn`
|
||||
*
|
||||
* Handles requests for user logon
|
||||
*
|
||||
* @param {Request} req The Express request
|
||||
@@ -77,6 +79,8 @@ export async function loginUserHandler(
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /api/v1/user/signUp`
|
||||
*
|
||||
* Handles requests for user registration
|
||||
*
|
||||
* @param {Request} req The Express request
|
||||
|
||||
79
src/controllers/linkController.ts
Normal file
79
src/controllers/linkController.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { Link } from '../entities/Link';
|
||||
import { User } from '../entities/User';
|
||||
import * as ms from '../schemas/miscSchema';
|
||||
import * as ls from '../schemas/linkSchema';
|
||||
import { LinkService } from '../services/linkService';
|
||||
import { UserService } from '../services/userService';
|
||||
import * as jwt from '../tools/jwt';
|
||||
import { generateSentenceString, generateShortString } from '../tools/wordlist';
|
||||
|
||||
/**
|
||||
* `GET /api/v1/link/short`
|
||||
*
|
||||
* Handles requests for short link generation
|
||||
*
|
||||
* @param {Request} req The Express request
|
||||
* @param {Response} res The Express resource
|
||||
*/
|
||||
export async function generateShortLinkHandler(
|
||||
req: Request<{}, {}, {}, ls.ShortLinkRequestDTO['query']>,
|
||||
res: Response
|
||||
) {
|
||||
|
||||
// Using locals here, as Request stores all parsed data
|
||||
// in strings for queries (QueryString.ParsedQs).
|
||||
const val = res.locals.validated as ls.ShortLinkRequestDTO;
|
||||
|
||||
let generatedShortString: string = generateShortString(
|
||||
val.query[ 'length'] ?? 9,
|
||||
val.query['alphanum'] ?? true,
|
||||
val.query[ 'case'] ?? null
|
||||
);
|
||||
let generatedSubdomain: string | null = null;
|
||||
|
||||
if (val.query['withSubdomain'] === true && jwt.getEnvString('useSubdomains', true) === 'true')
|
||||
generatedSubdomain = generateSentenceString('[subdomain]');
|
||||
|
||||
const userResponse: ls.LinkResponseDTO = {
|
||||
status: 'ok',
|
||||
uri: generatedShortString,
|
||||
subdomain: generatedSubdomain
|
||||
};
|
||||
|
||||
return res.status(200)
|
||||
.send(userResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /api/v1/link/fromWordlist`
|
||||
*
|
||||
* Handles requests for pseudo-sentence link generation
|
||||
*
|
||||
* @param {Request} req The Express request
|
||||
* @param {Response} res The Express resource
|
||||
*/
|
||||
export async function generateSentenceLinkHandler(
|
||||
req: Request<{}, {}, {}, ls.SentenceLinkRequestDTO['query']>,
|
||||
res: Response
|
||||
) {
|
||||
|
||||
// Using locals here, as Request stores all parsed data
|
||||
// in strings for queries (QueryString.ParsedQs).
|
||||
const val = res.locals.validated as ls.SentenceLinkRequestDTO;
|
||||
|
||||
let generatedSentenceString: string = generateSentenceString();
|
||||
let generatedSubdomain: string | null = null;
|
||||
|
||||
if (val.query['withSubdomain'] === true && jwt.getEnvString('useSubdomains', true) === 'true')
|
||||
generatedSubdomain = generateSentenceString('[subdomain]');
|
||||
|
||||
const userResponse: ls.LinkResponseDTO = {
|
||||
status: 'ok',
|
||||
uri: generatedSentenceString,
|
||||
subdomain: generatedSubdomain
|
||||
};
|
||||
|
||||
return res.status(200)
|
||||
.send(userResponse);
|
||||
}
|
||||
100
src/routes/linkRoutes.ts
Normal file
100
src/routes/linkRoutes.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Router } from 'express';
|
||||
import validateSchema from '../tools/validateSchema';
|
||||
import * as lc from '../controllers/linkController';
|
||||
import * as ls from '../schemas/linkSchema';
|
||||
|
||||
const linkRouter = Router();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
*
|
||||
* /api/v1/link/short:
|
||||
* get:
|
||||
* description: Generates a new short link
|
||||
* tags: [Link]
|
||||
* summary: Get a new short link
|
||||
* parameters:
|
||||
* - name: length
|
||||
* in: query
|
||||
* description: generated URL's length
|
||||
* required: false
|
||||
* schema:
|
||||
* type: integer
|
||||
* format: int32
|
||||
* default: 9
|
||||
* minimum: 2
|
||||
* maximum: 127
|
||||
* - name: alphanum
|
||||
* in: query
|
||||
* description: whether to use numbers in generated URL
|
||||
* required: false
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: true
|
||||
* - name: case
|
||||
* in: query
|
||||
* description: whether to use uppercase ("upper"), lowercase ("lower") or mixed case (default)
|
||||
* schema:
|
||||
* type: string
|
||||
* - name: withSubdomain
|
||||
* in: query
|
||||
* description: whether to generate a subdomain too (will be generated only if supported by server)
|
||||
* required: false
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* produces:
|
||||
* - application/json
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Link generated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LinkResponseDTO'
|
||||
* 400:
|
||||
* description: Bad request
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorDTO'
|
||||
*/
|
||||
linkRouter.get('/api/v1/link/short', validateSchema(ls.shortLinkRequestSchema), lc.generateShortLinkHandler);
|
||||
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
*
|
||||
* /api/v1/link/fromWordlist:
|
||||
* get:
|
||||
* description: Generates a new pseudosentence link from wordlist.
|
||||
* tags: [Link]
|
||||
* summary: Get a new "sentence" link
|
||||
* parameters:
|
||||
* - name: withSubdomain
|
||||
* in: query
|
||||
* description: whether to generate a subdomain too (will be generated only if supported by server)
|
||||
* required: false
|
||||
* schema:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* produces:
|
||||
* - application/json
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Link generated successfully
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/LinkResponseDTO'
|
||||
* 400:
|
||||
* description: Bad request
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ErrorDTO'
|
||||
*/
|
||||
linkRouter.get('/api/v1/link/fromWordlist', validateSchema(ls.sentenceLinkRequestSchema), lc.generateSentenceLinkHandler);
|
||||
|
||||
|
||||
export default linkRouter;
|
||||
@@ -21,6 +21,7 @@ miscRouter.get('/', (req: Request, res: Response) => res.send("Hello world!"));
|
||||
* get:
|
||||
* tags: [Healthcheck]
|
||||
* description: Provides a response when backend is running
|
||||
* summary: Check if backend is running
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Backend is online
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { Router } from 'express';
|
||||
import validateSchema from '../tools/validateSchema';
|
||||
import * as ac from '../controllers/authController';
|
||||
import * as as from '../schemas/authSchema';
|
||||
import * as as from '../schemas/authSchema';
|
||||
|
||||
const userRouter = Router();
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export type LoginRequestDTO = z.TypeOf<typeof loginRequestSchema>;
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* default: ok on success otherwise ErrorDTO with error
|
||||
* default: ok on success, otherwise ErrorDTO with error
|
||||
* name:
|
||||
* type: string
|
||||
* default: username
|
||||
|
||||
61
src/schemas/linkSchema.ts
Normal file
61
src/schemas/linkSchema.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import z from 'zod';
|
||||
|
||||
const shortLinkRequestSchemaQuery = z.object({
|
||||
// https://zod.dev/v4?id=stringbool
|
||||
length: z.coerce
|
||||
.number('Length must be a number')
|
||||
.min( 3, 'Length is too small (try something longer than 3)')
|
||||
.max(128, 'Length is too long (try something shorter than 128)')
|
||||
.optional(),
|
||||
alphanum: z.stringbool('Alphanum must be a boolean')
|
||||
.optional(),
|
||||
case: z.enum(['lower', 'upper'])
|
||||
.optional(),
|
||||
withSubdomain: z.stringbool('WithSubdomain must be a boolean')
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const shortLinkRequestSchema = z.object({
|
||||
query: shortLinkRequestSchemaQuery
|
||||
});
|
||||
export type ShortLinkRequestDTO = z.TypeOf<typeof shortLinkRequestSchema>;
|
||||
|
||||
const sentenceLinkRequestSchemaQuery = z.object({
|
||||
// https://zod.dev/v4?id=stringbool
|
||||
withSubdomain: z.stringbool('WithSubdomain must be a boolean')
|
||||
.optional()
|
||||
});
|
||||
|
||||
export const sentenceLinkRequestSchema = z.object({
|
||||
query: sentenceLinkRequestSchemaQuery
|
||||
});
|
||||
export type SentenceLinkRequestDTO = z.TypeOf<typeof sentenceLinkRequestSchema>;
|
||||
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* components:
|
||||
* schemas:
|
||||
* LinkResponseDTO:
|
||||
* type: object
|
||||
* required:
|
||||
* - status
|
||||
* - uri
|
||||
* - subdomain
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* default: ok on success, otherwise ErrorDTO with error
|
||||
* uri:
|
||||
* type: string
|
||||
* default: username
|
||||
* subdomain:
|
||||
* type: string
|
||||
* default: subdomain or null
|
||||
*/
|
||||
export type LinkResponseDTO = {
|
||||
status: 'ok';
|
||||
uri: string;
|
||||
subdomain?: string | null; // null when server does not support generating subdomains
|
||||
};
|
||||
|
||||
137
src/services/linkService.ts
Normal file
137
src/services/linkService.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Link } from '../entities/Link';
|
||||
import { User } from '../entities/User';
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { getEnvString } from '../tools/jwt';
|
||||
|
||||
export type IdResponse = {
|
||||
id: number;
|
||||
exists: boolean;
|
||||
};
|
||||
|
||||
export class LinkService {
|
||||
dataSource = AppDataSource;
|
||||
linkRepo = this.dataSource.getRepository(Link);
|
||||
|
||||
// Retrieve config to check whether subdomains are allowed
|
||||
useSubdomains: boolean = getEnvString('useSubdomains', true) === 'true';
|
||||
|
||||
/**
|
||||
* Simply insert a new link entity anonymously.
|
||||
*
|
||||
* @param {Link} link The link to insert
|
||||
*/
|
||||
async addAnonymous(link: Link): Promise<IdResponse> {
|
||||
let result: IdResponse = { id: -1, exists: false };
|
||||
|
||||
// Sanity check: don't allow for adding links
|
||||
// with subdomains if server has it disabled.
|
||||
if (link.subdomain && !this.useSubdomains) link.subdomain = null;
|
||||
|
||||
// Check if entry can be inserted.
|
||||
if (await this.canInsert(link)) {
|
||||
await this.linkRepo.insert(link);
|
||||
|
||||
// Then get new link's ID
|
||||
const insertedLink: Link[] = await this.linkRepo.findBy({
|
||||
shortUri: link.shortUri,
|
||||
fullUrl: link.fullUrl
|
||||
});
|
||||
|
||||
// Return appropriate id (or error)
|
||||
if (insertedLink.length !== 1) {
|
||||
result.id = -2;
|
||||
result.exists = false;
|
||||
} else {
|
||||
result.id = insertedLink[0].id;
|
||||
result.exists = true;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new link entity, and links it with passed user.
|
||||
*
|
||||
* USE ONLY FOR AUTHENTICATED USERS. For unauthenticated users
|
||||
* use addAnonymous() instead. Returns IdResponse, containing
|
||||
* exists field indicating if an existing field has been found,
|
||||
* and if so, what is it's id. If exists is false, ID of the
|
||||
* newly created entity gets returned.
|
||||
*
|
||||
* Errors: an error ocurred if ID is negative.
|
||||
*
|
||||
* This can be:
|
||||
*
|
||||
* - -1 - link can't be inserted (because an entry with shortUri or shortUri+subdomain combo exist),
|
||||
*
|
||||
* - -2 - no conflicting entry exists but transaction failed anyway.
|
||||
*
|
||||
* @param {Link} link The link
|
||||
* @param {User} user The user
|
||||
* @return {Promise<IdResponse>} Dictionary containing link ID and whether it already existed, or was just inserted
|
||||
*/
|
||||
async addIfNew(link: Link, user: User): Promise<IdResponse> {
|
||||
|
||||
let result: IdResponse = { id: -1, exists: false };
|
||||
|
||||
// If no conflicts are found,
|
||||
// proceed with creating a new link entry.
|
||||
if (await this.canInsert(link)) {
|
||||
// Commit a transaction
|
||||
this.dataSource.transaction(async (t) => {
|
||||
link.author = user;
|
||||
user.links.push(link);
|
||||
t.insert(Link, link);
|
||||
t.save(user);
|
||||
});
|
||||
|
||||
// Then get new link's ID
|
||||
const insertedLink: Link[] = await this.linkRepo.findBy({
|
||||
shortUri: link.shortUri,
|
||||
fullUrl: link.fullUrl
|
||||
});
|
||||
|
||||
// Return appropriate id (or error)
|
||||
if (insertedLink.length !== 1) {
|
||||
result.id = -2;
|
||||
result.exists = false;
|
||||
} else {
|
||||
result.id = insertedLink[0].id;
|
||||
result.exists = true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async canInsert(link: Link): Promise<boolean> {
|
||||
let shortUriExists: boolean = true
|
||||
|
||||
// If both subdomains are enabled, and user
|
||||
// provided a subdomain, find if a record exists
|
||||
// that either has the exact shortUri
|
||||
// or shortUri and subdomain combo.
|
||||
// If any of these turns out to be true, the request
|
||||
// must be invalidated and we can't proceed
|
||||
// with creation of a new link.
|
||||
if (this.useSubdomains && link.subdomain)
|
||||
{
|
||||
shortUriExists = await this.linkRepo.existsBy({
|
||||
shortUri: link.shortUri,
|
||||
subdomain: link.subdomain
|
||||
});
|
||||
}
|
||||
// If custom subdomains are disabled, fallback to
|
||||
// checking only by the URIs - thus discarding
|
||||
// any possible subdomains.
|
||||
else
|
||||
{
|
||||
shortUriExists = await this.linkRepo.existsBy({ shortUri: link.shortUri });
|
||||
}
|
||||
|
||||
return !shortUriExists;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -8,11 +8,12 @@ const validate =
|
||||
(schema: z.ZodObject) =>
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
schema.parse({
|
||||
let validatedData = schema.parse({
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
params: req.params,
|
||||
});
|
||||
res.locals.validated = validatedData;
|
||||
next();
|
||||
} catch (e: any) {
|
||||
if (e instanceof z.ZodError) {
|
||||
@@ -24,7 +25,7 @@ const validate =
|
||||
return res.status(400)
|
||||
.json(errorResponse);
|
||||
} else {
|
||||
console.log('Generic error triggered:', e);
|
||||
console.log('Generic validation error triggered:', e);
|
||||
let errorResponse: ErrorDTO = {
|
||||
status: 'error',
|
||||
error: 'Unknown error',
|
||||
|
||||
7069
wordlist.example-large.ts
Normal file
7069
wordlist.example-large.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user