feat: add link creation and lookup
All checks were successful
Build and push Docker image / build (push) Successful in 2m50s
Release new version / release (push) Successful in 26s
Update changelog / changelog (push) Successful in 25s

finally has the bare minimum functionality to say that it works!
This commit is contained in:
2026-01-03 10:51:59 +01:00
parent c19a098b1c
commit d7f4006698
8 changed files with 477 additions and 34 deletions

View File

@@ -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": {

View File

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

View File

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

View File

@@ -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. <br/>
* 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;

View File

@@ -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<typeof createLinkRequestSchema>;
/**
* @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;
};

View File

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

View File

@@ -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<IdResponse> {
let result: IdResponse = { id: -1, exists: false };
async addIfNewAnonymous(link: Link): Promise<IdResponse> {
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<IdResponse> {
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<number>} Amount of removed rows.
*/
async removeAllExpired(): Promise<number> {
// 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|null>)} Link (or null if not found)
*/
async findById(id: number): Promise<Link | null> {
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|null>)} Link entity containing provided params
*/
async lookupUri(uri: string, subdomain: string | null = null): Promise<Link | null> {
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|null>)} Link entity containing provided params
*/
async lookupUriWithExpiryValidation(uri: string, subdomain: string | null = null): Promise<Link | null> {
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);
}
}

View File

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