feat: add link generation support (short/sentence links) + wordlist
All checks were successful
Update changelog / changelog (push) Successful in 26s

This commit is contained in:
2026-01-02 17:50:52 +01:00
parent f0ca4e897c
commit f86630c51e
12 changed files with 7462 additions and 6 deletions

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ temp/
# .env # .env
.env .env
# wordlist
src/tools/wordlist.ts

View File

@@ -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 = {

View File

@@ -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

View 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
View 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;

View File

@@ -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

View File

@@ -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();

View File

@@ -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
View 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
View 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;
}
}

View File

@@ -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

File diff suppressed because it is too large Load Diff