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
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# wordlist
|
||||||
|
src/tools/wordlist.ts
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AppDataSource } from './data-source'
|
|||||||
import inferUser from './middleware/inferUser';
|
import inferUser from './middleware/inferUser';
|
||||||
import miscRouter from './routes/miscRoutes';
|
import miscRouter from './routes/miscRoutes';
|
||||||
import userRouter from './routes/userRoutes';
|
import userRouter from './routes/userRoutes';
|
||||||
|
import linkRouter from './routes/linkRoutes';
|
||||||
import { getCorsConfig } from './tools/cors';
|
import { getCorsConfig } from './tools/cors';
|
||||||
|
|
||||||
AppDataSource.initialize().then(async () => {
|
AppDataSource.initialize().then(async () => {
|
||||||
@@ -18,7 +19,7 @@ AppDataSource.initialize().then(async () => {
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(getCorsConfig());
|
app.use(getCorsConfig());
|
||||||
app.use(inferUser);
|
app.use(inferUser);
|
||||||
app.use(miscRouter, userRouter);
|
app.use(miscRouter, userRouter, linkRouter);
|
||||||
|
|
||||||
if (process.env['DEBUG'] === 'true') {
|
if (process.env['DEBUG'] === 'true') {
|
||||||
const swaggerJsdocOpts = {
|
const swaggerJsdocOpts = {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { generateSha512 } from '../tools/hasher';
|
|||||||
import * as jwt from '../tools/jwt';
|
import * as jwt from '../tools/jwt';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* `POST /api/v1/user/signIn`
|
||||||
|
*
|
||||||
* Handles requests for user logon
|
* Handles requests for user logon
|
||||||
*
|
*
|
||||||
* @param {Request} req The Express request
|
* @param {Request} req The Express request
|
||||||
@@ -77,6 +79,8 @@ export async function loginUserHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* `POST /api/v1/user/signUp`
|
||||||
|
*
|
||||||
* Handles requests for user registration
|
* Handles requests for user registration
|
||||||
*
|
*
|
||||||
* @param {Request} req The Express request
|
* @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:
|
* get:
|
||||||
* tags: [Healthcheck]
|
* tags: [Healthcheck]
|
||||||
* description: Provides a response when backend is running
|
* description: Provides a response when backend is running
|
||||||
|
* summary: Check if backend is running
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Backend is online
|
* description: Backend is online
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router } from 'express';
|
||||||
import validateSchema from '../tools/validateSchema';
|
import validateSchema from '../tools/validateSchema';
|
||||||
import * as ac from '../controllers/authController';
|
import * as ac from '../controllers/authController';
|
||||||
import * as as from '../schemas/authSchema';
|
import * as as from '../schemas/authSchema';
|
||||||
|
|
||||||
const userRouter = Router();
|
const userRouter = Router();
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export type LoginRequestDTO = z.TypeOf<typeof loginRequestSchema>;
|
|||||||
* properties:
|
* properties:
|
||||||
* status:
|
* status:
|
||||||
* type: string
|
* type: string
|
||||||
* default: ok on success otherwise ErrorDTO with error
|
* default: ok on success, otherwise ErrorDTO with error
|
||||||
* name:
|
* name:
|
||||||
* type: string
|
* type: string
|
||||||
* default: username
|
* 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) =>
|
(schema: z.ZodObject) =>
|
||||||
(req: Request, res: Response, next: NextFunction) => {
|
(req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
schema.parse({
|
let validatedData = schema.parse({
|
||||||
body: req.body,
|
body: req.body,
|
||||||
query: req.query,
|
query: req.query,
|
||||||
params: req.params,
|
params: req.params,
|
||||||
});
|
});
|
||||||
|
res.locals.validated = validatedData;
|
||||||
next();
|
next();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e instanceof z.ZodError) {
|
if (e instanceof z.ZodError) {
|
||||||
@@ -24,7 +25,7 @@ const validate =
|
|||||||
return res.status(400)
|
return res.status(400)
|
||||||
.json(errorResponse);
|
.json(errorResponse);
|
||||||
} else {
|
} else {
|
||||||
console.log('Generic error triggered:', e);
|
console.log('Generic validation error triggered:', e);
|
||||||
let errorResponse: ErrorDTO = {
|
let errorResponse: ErrorDTO = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: 'Unknown 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