From d7f400669836aba2daac70c7e4000c68768581ca Mon Sep 17 00:00:00 2001 From: sherl Date: Sat, 3 Jan 2026 10:51:59 +0100 Subject: [PATCH] feat: add link creation and lookup finally has the bare minimum functionality to say that it works! --- package.json | 2 +- src/app.ts | 77 +++++++++++++++--- src/controllers/linkController.ts | 128 ++++++++++++++++++++++++++++- src/routes/linkRoutes.ts | 38 +++++++++ src/schemas/linkSchema.ts | 81 +++++++++++++++++++ src/schemas/miscSchema.ts | 6 ++ src/services/linkService.ts | 129 +++++++++++++++++++++++++----- src/tools/env.ts | 50 ++++++++++++ 8 files changed, 477 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 99f93aa..3bf4ee0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kittyBE", - "version": "0.0.0", + "version": "0.0.1", "description": "Your go-to place for short and memorable URLs.", "type": "commonjs", "devDependencies": { diff --git a/src/app.ts b/src/app.ts index 40eba41..d521695 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,27 +1,33 @@ -import * as dotenv from 'dotenv'; -dotenv.config({ quiet: true }); - import express from 'express'; import { version } from '../package.json'; import { AppDataSource } from './data-source' +import { Link } from './entities/Link'; 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'; +import { LinkService } from './services/linkService'; +import * as env from './tools/env'; +import * as z from 'zod'; AppDataSource.initialize().then(async () => { await AppDataSource.runMigrations(); const app: express.Express = express(); + const linkService = new LinkService(); + const rs = env.getRewriteStrings(); + + const removedExpired = await linkService.removeAllExpired(); + if (removedExpired !== 0) console.log(`[${Date.now() / 1_000}] (DB) Removed ${removedExpired} expired links.`); app.use(express.json()); app.use(getCorsConfig()); app.use(inferUser); app.use(miscRouter, userRouter, linkRouter); - if (process.env['DEBUG'] === 'true') { + if (env.getBool('debug', true)) { const swaggerJsdocOpts = { failOnErrors: true, definition: { @@ -67,16 +73,65 @@ AppDataSource.initialize().then(async () => { // Handle 404s // https://stackoverflow.com/a/9802006 - app.use(function(req: express.Request, res: express.Response) { - res.status(404); + app.use(async function(req: express.Request, res: express.Response) { - if (req.accepts('json')) { - res.json({ status: 'error', error: 'Not found' }); - return; + // Check if host header seems right + try { + z.string() + .includes(rs.fqdn) + .parse(req.headers.host); + } catch { + return res.status(400) + .json({ + status: 'error', + error: 'Invalid host. Is your browser sending the host header?', + code: 'no_host' + }); + } + + // Retrieve url, subdomain from request. + let uri: string = req.url.slice(1); // discards / from /abc, /abc -> abc + let subdomain: string | null = req.headers.host!.replace(rs.fqdn, '') || null; + + // Try to lookup the url in DB + const reversedLink: Link | null = await linkService.lookupUriWithExpiryValidation(uri, subdomain); + + // Found something? + if (reversedLink !== null) { + // Count this as a visit + reversedLink.visits += 1; + linkService.save(reversedLink); + + // Redirect the user. + return res.redirect(302, reversedLink.fullUrl); } - res.type('txt').send('Not found'); + // Nothing found? Return the standard 404. + res.status(404); + if (req.accepts('json')) { + return res.json({ + status: 'error', + error: 'Not found', + code: 'uri_not_found' + }); + } + + return res.type('txt') + .send('Not found'); }); - app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.')); + + const errorHandler: express.ErrorRequestHandler = (err, req, res, next) => { + console.error(`[${Date.now() / 1_000}] (ErrorHandler) Server error! Error stack:`); + console.error(err?.stack); + return res.status(500) + .json({ + status: 'error', + error: 'Server error! Something broke', + code: 'generic_server_error' + }); + }; + app.use(errorHandler); + + app.listen(6567, () => console.log(`[${Date.now() / 1_000}] (HTTP Server) Listening on port 6567.`)); }).catch(error => console.log(error)) diff --git a/src/controllers/linkController.ts b/src/controllers/linkController.ts index 9bc65b6..734c931 100644 --- a/src/controllers/linkController.ts +++ b/src/controllers/linkController.ts @@ -3,9 +3,10 @@ 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 { LinkService, IdResponse } from '../services/linkService'; import { UserService } from '../services/userService'; import * as env from '../tools/env'; +import * as jwt from '../tools/jwt'; import { generateSentenceString, generateShortString } from '../tools/wordlist'; /** @@ -77,3 +78,128 @@ export async function generateSentenceLinkHandler( return res.status(200) .send(userResponse); } + + +/** + * `POST /api/v1/link/new` + * + * Handles requests for submitting a new shortened URL + * + * @param {Request} req The Express request + * @param {Response} res The Express resource + */ +export async function createLinkHandler( + req: Request<{}, {}, ls.CreateLinkRequestDTO['body']>, + res: Response +) { + + // Using locals to retrieve decoded user JWT. + const decodedUser: jwt.JwtDecoded | undefined = res.locals.user?.decoded; + const linkService = new LinkService(); + const subdomainsAllowed: boolean = env.getBool('useSubdomains', true)!; + const rewriteStrings: env.RewriteStrings = env.getRewriteStrings(); + + // Sanity check: does the uri start with a forbidden schema? + const disallowedResult = ms.disallowedUriSchema.safeParse(req.body.uri); + + // Uri is part of forbidden schema + if (disallowedResult.success) { + const error: ms.ErrorDTO = { + status: 'error', + error: 'This uri starts with a forbidden keyword', + code: 'forbidden_schema' + } + return res.status(406) + .send(error); + } + + let user: User | null = null; + if (decodedUser !== undefined) { + // If user is logged in, retrieve the account. + const userService = new UserService(); + user = await userService.findById(decodedUser.sub); + } + + let generatedSubdomain: string | null = null; + // Subdomain passed, but isn't supported? Return an error. + if (req.body.subdomain !== undefined && !subdomainsAllowed) { + const error: ms.ErrorDTO = { + status: 'error', + error: 'Server configuration disallows usage of subdomain', + code: 'server_subdomain_disabled' + }; + return res.status(400) + .send(error); + } + // Subdomain passed, and server config allows it? Then use it. + else if (req.body.subdomain !== undefined && subdomainsAllowed) + generatedSubdomain = req.body.subdomain; + + // Similarly, check if expiry date has been passed. + let expiryDate: number | null = null; + if (req.body.expiryDate !== undefined) + expiryDate = req.body.expiryDate; + + // Construct the link + const createDate = Date.now(); + const link: Link = { + id: 0, // Can we? Seems like so. + subdomain: generatedSubdomain, + shortUri: req.body.uri, + fullUrl: req.body.remoteUrl, + createDate, + expiryDate, + visits: 0, + privacy: req.body.privacy ?? true, + author: user + }; + + // Try to add the row + let returnedId: IdResponse | null = null; + if (user === null) { + // Add anonymously + returnedId = await linkService.addIfNewAnonymous(link); + } else { + // Use a transaction to add the link, + // and link it to the user. + returnedId = await linkService.addIfNew(link, user); + } + + // Failed (short uri + if enabled, subdomain combo is taken)? + if (returnedId.exists && returnedId.id == -1) { + console.log(returnedId); + const error: ms.ErrorDTO = { + status: 'error', + error: `"${req.body.uri}" is already taken. Maybe try "${generateSentenceString()}"?`, + code: 'uri_not_unique' + }; + return res.status(403) + .send(error); + } + + // Some other, unknown error occurred. + if (returnedId.id < 0) { + console.log(returnedId); + const error: ms.ErrorDTO = { + status: 'error', + error: 'Server error', + code: 'generic_error' + }; + return res.status(500) + .send(error); + } + + // If we've arrived this far, seems like it's safe + // to assume everything went OK. + const rs = rewriteStrings; + const sd = req.body.subdomain; + const shortenedUrl = `${rs.proto}://${sd ? sd + '.' : ''}${rs.fqdn}${rs.path}${req.body.uri}`; + const userResponse: ls.CreateLinkResponseDTO = { + status: 'ok', + uri: shortenedUrl, + id: returnedId.id + }; + + return res.status(200) + .send(userResponse); +} \ No newline at end of file diff --git a/src/routes/linkRoutes.ts b/src/routes/linkRoutes.ts index ac1fbbd..ef0910c 100644 --- a/src/routes/linkRoutes.ts +++ b/src/routes/linkRoutes.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import validateSchema from '../tools/validateSchema'; import * as lc from '../controllers/linkController'; import * as ls from '../schemas/linkSchema'; +import requireUser from '../middleware/requireUser'; const linkRouter = Router(); @@ -96,5 +97,42 @@ linkRouter.get('/api/v1/link/short', validateSchema(ls.shortLinkRequestSchema), */ linkRouter.get('/api/v1/link/fromWordlist', validateSchema(ls.sentenceLinkRequestSchema), lc.generateSentenceLinkHandler); +/** + * @openapi + * + * /api/v1/link/new: + * post: + * description: + * Register a new shortened URL.
+ * See linkSchema.ts for constraints. + * tags: [Link] + * summary: Shorten a link + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateLinkRequestDTO' + * produces: + * - application/json + * responses: + * 200: + * description: New link created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateLinkResponseDTO' + * 400: + * description: Bad request + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorDTO' + */ +linkRouter.post('/api/v1/link/new', + validateSchema(ls.createLinkRequestSchema), + lc.createLinkHandler +); + export default linkRouter; \ No newline at end of file diff --git a/src/schemas/linkSchema.ts b/src/schemas/linkSchema.ts index 5ced090..d89a5fb 100644 --- a/src/schemas/linkSchema.ts +++ b/src/schemas/linkSchema.ts @@ -62,3 +62,84 @@ export type LinkResponseDTO = { subdomain?: string | null; // null when server does not support generating subdomains }; + +// POST /api/v1/link/short +/** + * @openapi + * components: + * schemas: + * CreateLinkRequestDTO: + * type: object + * required: + * - uri + * - remoteUrl + * properties: + * uri: + * type: string + * default: short uri + * remoteUrl: + * type: string + * default: source url + * subdomain: + * type: string + * default: optional subdomain + * privacy: + * type: boolean + * default: true # privacy by default + * expiryDate: + * type: number + * default: 1767413102824 # UNIX timestamp in ms, Date.now() + */ +const createLinkRequestSchemaBody = z.object({ + uri: z.string({ + error: (e) => + e.input === undefined ? 'Uri is required' : '/, ?, &, = and & are not allowed' + }).min( 3, 'Shortened Uri must be at least 3 characters long') + .max(128, 'Shortened Uri cannot be longer than 128 characters') + .regex(/^[^\/?&=#]*$/), + remoteUrl: z.url({ + error: (e) => + e.input === undefined ? 'RemoteUrl is required' : 'RemoteUrl is not a valid URL' + }), + privacy: z.boolean({ error: 'Privacy must be a boolean (true by default)' }) + .optional(), + subdomain: z.string('Subdomain must be a string of length between 1 and 32') + .min( 1, 'Subdomain should be at least 1 character long') + .max(32, 'Subdomain should not be longer than 32 characters') + .regex(/^[a-zA-Z0-9]*$/) + .optional(), + expiryDate: z.number('Expiry date must be a number (UNIX timestamp with milliseconds)') + .min(Date.now(), 'Expiry date is a UNIX timestamp with milliseconds') + .optional() +}); + +export const createLinkRequestSchema = z.object({ + body: createLinkRequestSchemaBody +}); +export type CreateLinkRequestDTO = z.TypeOf; + +/** + * @swagger + * components: + * schemas: + * CreateLinkResponseDTO: + * type: object + * required: + * - status + * - uri + * - subdomain + * properties: + * status: + * type: string + * default: ok on success, otherwise ErrorDTO with error + * uri: + * type: string + * default: full public, shortened url + * id: + * type: number + */ +export type CreateLinkResponseDTO = { + status: 'ok'; + uri: string; + id: number; +}; diff --git a/src/schemas/miscSchema.ts b/src/schemas/miscSchema.ts index f7be6a7..0f0cd24 100644 --- a/src/schemas/miscSchema.ts +++ b/src/schemas/miscSchema.ts @@ -1,3 +1,4 @@ +import * as z from 'zod'; /** * @openapi @@ -24,3 +25,8 @@ export type ErrorDTO = { error: string; code?: string | undefined; }; + +// Used to check against reserved names. +export const disallowedUriSchema = z + .string() + .regex(/^(about|assets|kttydocs|panel)/); diff --git a/src/services/linkService.ts b/src/services/linkService.ts index c261dae..7b823db 100644 --- a/src/services/linkService.ts +++ b/src/services/linkService.ts @@ -1,7 +1,8 @@ import { Link } from '../entities/Link'; import { User } from '../entities/User'; import { AppDataSource } from '../data-source'; -import { getEnvString } from '../tools/jwt'; +import * as env from '../tools/env'; +import { IsNull, LessThan } from 'typeorm'; export type IdResponse = { id: number; @@ -13,15 +14,15 @@ export class LinkService { linkRepo = this.dataSource.getRepository(Link); // Retrieve config to check whether subdomains are allowed - useSubdomains: boolean = getEnvString('useSubdomains', true) === 'true'; + useSubdomains: boolean = env.getBool('useSubdomains', true)!; /** * Simply insert a new link entity anonymously. * * @param {Link} link The link to insert */ - async addAnonymous(link: Link): Promise { - let result: IdResponse = { id: -1, exists: false }; + async addIfNewAnonymous(link: Link): Promise { + let result: IdResponse = { id: -1, exists: true }; // Sanity check: don't allow for adding links // with subdomains if server has it disabled. @@ -33,16 +34,17 @@ export class LinkService { // Then get new link's ID const insertedLink: Link[] = await this.linkRepo.findBy({ - shortUri: link.shortUri, - fullUrl: link.fullUrl + // Add subdomain if used, https://stackoverflow.com/a/69151874 + subdomain: link.subdomain ?? IsNull(), + shortUri: link.shortUri }); // Return appropriate id (or error) - if (insertedLink.length !== 1) { - result.id = -2; - result.exists = false; - } else { + if (insertedLink.length === 1) { result.id = insertedLink[0].id; + result.exists = false; + } else { + result.id = -2; result.exists = true; } } @@ -73,31 +75,29 @@ export class LinkService { */ async addIfNew(link: Link, user: User): Promise { - let result: IdResponse = { id: -1, exists: false }; + let result: IdResponse = { id: -1, exists: true }; // 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) => { + await 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 + subdomain: link.subdomain ?? IsNull(), + shortUri: link.shortUri }); // Return appropriate id (or error) - if (insertedLink.length !== 1) { - result.id = -2; - result.exists = false; - } else { + if (insertedLink.length === 1) { result.id = insertedLink[0].id; + result.exists = false; + } else { + result.id = -2; result.exists = true; } @@ -128,10 +128,97 @@ export class LinkService { // any possible subdomains. else { - shortUriExists = await this.linkRepo.existsBy({ shortUri: link.shortUri }); + shortUriExists = await this.linkRepo.existsBy({ + shortUri: link.shortUri, + subdomain: IsNull() + }); } return !shortUriExists; } + /** + * Removes all expired links. + * + * @return {Promise} Amount of removed rows. + */ + async removeAllExpired(): Promise { + // https://github.com/typeorm/typeorm/issues/960#issuecomment-489554674 + + let rowsRemoved = 0; + const currentDate = Date.now(); + + rowsRemoved = await this.linkRepo.countBy({ expiryDate: LessThan(currentDate) }); + const affectedRows = await this.linkRepo.delete({ expiryDate: LessThan(currentDate) }); + + return rowsRemoved; + } + + /** + * Finds a Link by it's identifier. + * + * @param {number} id The identifier + * @return {(Promise)} Link (or null if not found) + */ + async findById(id: number): Promise { + return await this.linkRepo.findOneBy({id}); + } + + /** + * Lookup the uri + subdomain combo. + * + * @param {string} uri The uri + * @param {(null|string)} [subdomain=null] The subdomain + * @return {(Promise)} Link entity containing provided params + */ + async lookupUri(uri: string, subdomain: string | null = null): Promise { + let result: Link | null = null; + + result = await this.linkRepo.findOneBy({ + shortUri: uri, + subdomain: subdomain ?? IsNull() + }) ?? null; + + return result; + } + + /** + * Lookup the uri + subdomain combo. + * AUTOMATICALLY REMOVES the entry if it expired. + * + * @param {string} uri The uri + * @param {(null|string)} [subdomain=null] The subdomain + * @return {(Promise)} Link entity containing provided params + */ + async lookupUriWithExpiryValidation(uri: string, subdomain: string | null = null): Promise { + let result: Link | null = null; + + result = await this.linkRepo.findOneBy({ + shortUri: uri, + subdomain: subdomain ?? IsNull() + }) ?? null; + + const now = Date.now(); + if (result !== null && result.expiryDate !== null) + if (now > (result.expiryDate ?? now + 1)) { + // Remove found entry + this.linkRepo.remove(result); + // Set to null to not propagate expired link + result = null; + } + + return result; + } + + /** + * Save a link. + * Saves all changes made to the link entity. + * + * @param {Link} link The link + * @return {Promise} None + */ + async save(link: Link) { + await this.linkRepo.save(link); + } + } diff --git a/src/tools/env.ts b/src/tools/env.ts index 464d752..8921484 100644 --- a/src/tools/env.ts +++ b/src/tools/env.ts @@ -35,3 +35,53 @@ export function getString( return process.env[keyName]; } +/** + * Get the environmental boolean from .env. + * Supports rewriting names to UPPER_SNAKE_CASE if isGlobal is set. + * + * @param {string} key The key + * @param {boolean} [isGlobal=true] Indicates if global + * @return {(boolean|undefined)} The environment boolean. + */ +export function getBool( + key: string, + isGlobal: boolean = true +): boolean | undefined { + + const valueRead: string | undefined = getString(key, isGlobal); + if (valueRead === undefined) return undefined; + if (valueRead.toLowerCase() === 'true') + return true; + + return false; +} + +/** + * Processes public url, returning protocol, fqdn and path. + * proto://fqdn/path/ = fullPublicUrl + * @return {RewriteStrings} The rewrite strings. + */ +export function getRewriteStrings(): RewriteStrings { + const fullPublicUrl: string = getString('publicUrl', true)!; + + const url = new URL(fullPublicUrl); + const proto = url.protocol.slice(0, -1); // https: -> https + const fqdn = url.host; + const path = url.pathname.replace(/\/+$/, '') + '/'; // /abc -> /abc/ + + const result: RewriteStrings = { + fullPublicUrl, + proto, + fqdn, + path + }; + + return result; +}; + +export type RewriteStrings = { + fullPublicUrl: string; + proto: string; + fqdn: string; + path: string; +};