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