4 Commits

Author SHA1 Message Date
d7f4006698 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!
2026-01-03 10:51:59 +01:00
c19a098b1c feat: add sample endpoint to test JWT 2026-01-03 04:37:20 +01:00
ec5cedce5a fix: actually support JWT bearer authentication 2026-01-03 00:29:45 +01:00
4bf39c7fdf chore: offload retrieval of environment variables from jwt.ts to env.ts 2026-01-02 22:57:03 +01:00
16 changed files with 699 additions and 97 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: {
@@ -50,7 +56,7 @@ AppDataSource.initialize().then(async () => {
}
},
security: [
{ bearerAuth: [] }
{ BearerJWT: [] }
],
},
apis: ['./src/routes/*.ts', './src/schemas/*.ts']
@@ -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'
});
}
res.type('txt').send('Not found');
// 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);
}
// 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'
});
app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));
}
return res.type('txt')
.send('Not found');
});
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

@@ -188,3 +188,43 @@ export async function createUserHandler(
return res.status(201)
.send(userResponse);
}
/**
* `GET /api/v1/user/account`
*
* Handles requests for user info retrieval
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function getUserHandler(
req: Request,
res: Response
) {
const userService = new UserService();
const user: jwt.JwtDecoded = res.locals.user.decoded;
// Get detailed data from DB
let existingUser: User | null = await userService.findById(user.sub);
if (existingUser === null) {
const error: ms.ErrorDTO = {
status: 'error',
error: 'User does not exist',
code: 'deleted_user'
};
return res.status(404)
.json(error);
}
// Respond with ShortUserInfoDTO
const userResponse: as.ShortUserInfoDTO = {
status: 'ok',
id: existingUser.id!,
name: existingUser.name,
role: existingUser.role
}
return res.status(201)
.send(userResponse);
}

View File

@@ -3,8 +3,9 @@ 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';
@@ -32,7 +33,7 @@ export async function generateShortLinkHandler(
);
let generatedSubdomain: string | null = null;
if (val.query['withSubdomain'] === true && jwt.getEnvString('useSubdomains', true) === 'true')
if (val.query['withSubdomain'] === true && env.getString('useSubdomains', true) === 'true')
generatedSubdomain = generateSentenceString('[subdomain]');
const userResponse: ls.LinkResponseDTO = {
@@ -65,7 +66,7 @@ export async function generateSentenceLinkHandler(
let generatedSentenceString: string = generateSentenceString();
let generatedSubdomain: string | null = null;
if (val.query['withSubdomain'] === true && jwt.getEnvString('useSubdomains', true) === 'true')
if (val.query['withSubdomain'] === true && env.getString('useSubdomains', true) === 'true')
generatedSubdomain = generateSentenceString('[subdomain]');
const userResponse: ls.LinkResponseDTO = {
@@ -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,7 +2,7 @@
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/deserializeUser.ts
import { get } from 'lodash';
import { Request, Response, NextFunction } from 'express';
import { verifyJwt } from '../tools/jwt';
import * as jwt from '../tools/jwt';
const inferUser = async (
req: Request,
@@ -17,12 +17,9 @@ const inferUser = async (
if (!accessToken) return next();
const { decoded } = verifyJwt(accessToken, 'accessTokenPublicKey');
// console.log('decoded user:', decoded);
if (decoded) {
res.locals.user = decoded;
const token = jwt.verifyJwt(accessToken, 'accessTokenPrivateKey');
if (token) {
res.locals.user = token;
return next();
}

View File

@@ -0,0 +1,41 @@
import { Request, Response, NextFunction } from "express";
import { ErrorDTO } from "../schemas/miscSchema";
import * as jwt from "../tools/jwt";
const requireUser = (req: Request, res: Response, next: NextFunction) => {
const user: jwt.JwtStatus = res.locals.user;
let error: ErrorDTO | null = null;
// No user? Something errored partway. Display an error.
if (!user)
error = {
status: 'error',
error: 'Unauthorized, please sign in',
code: 'unauthorized_generic'
};
// Check if token is expired first.
// This is because a token can be valid
// (if signature matches) while being expired.
else if (user.expired)
error = {
status: 'error',
error: 'Token expired, please sign in again',
code: 'expired_token'
};
// Previous checks failed?
// As a last resort, check if the token is valid.
else if (!user.valid)
error = {
status: 'error',
error: 'Invalid token, please sign in',
code: 'invalid_token'
};
if (error !== null)
return res.status(401)
.send(error);
return next();
};
export default requireUser;

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

@@ -2,6 +2,7 @@ import { Router } from 'express';
import validateSchema from '../tools/validateSchema';
import * as ac from '../controllers/authController';
import * as as from '../schemas/authSchema';
import requireUser from '../middleware/requireUser';
const userRouter = Router();
@@ -69,4 +70,33 @@ userRouter.post('/api/v1/user/signUp', validateSchema(as.loginRequestSchema), ac
*/
userRouter.post('/api/v1/user/signIn', validateSchema(as.loginRequestSchema), ac.loginUserHandler);
/**
* @openapi
*
* /api/v1/user/account:
* get:
* description: Get authenticated user info
* tags: [User]
* summary: "[AUTHED] Get user info"
* produces:
* - application/json
* responses:
* 200:
* description: User logged in successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ShortUserInfoDTO'
* 400:
* description: Wrong password/non-existent user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
userRouter.get('/api/v1/user/account',
requireUser,
ac.getUserHandler
);
export default userRouter;

View File

@@ -80,3 +80,33 @@ export type UserInfoDTO = {
ttl: number | null;
};
/**
* @swagger
* components:
* schemas:
* ShortUserInfoDTO:
* type: object
* required:
* - status
* - id
* - name
* - role
* properties:
* status:
* type: string
* default: ok on success, otherwise ErrorDTO with error
* id:
* type: number
* name:
* type: string
* default: username
* role:
* type: number
* default: 0 # 0 - standard user, 1 - administrator
*/
export type ShortUserInfoDTO = {
status: 'ok';
id: number;
name: string;
role: number;
};

View File

@@ -1,5 +1,6 @@
import z from 'zod';
// GET /api/v1/link/short
const shortLinkRequestSchemaQuery = z.object({
// https://zod.dev/v4?id=stringbool
length: z.coerce
@@ -20,6 +21,7 @@ export const shortLinkRequestSchema = z.object({
});
export type ShortLinkRequestDTO = z.TypeOf<typeof shortLinkRequestSchema>;
// GET /api/v1/link/fromWordlist
const sentenceLinkRequestSchemaQuery = z.object({
// https://zod.dev/v4?id=stringbool
withSubdomain: z.stringbool('WithSubdomain must be a boolean')
@@ -31,6 +33,7 @@ export const sentenceLinkRequestSchema = z.object({
});
export type SentenceLinkRequestDTO = z.TypeOf<typeof sentenceLinkRequestSchema>;
// response for both /api/v1/link/short and /api/v1/link/fromWordlist
/**
* @swagger
@@ -48,7 +51,7 @@ export type SentenceLinkRequestDTO = z.TypeOf<typeof sentenceLinkRequestSchema>;
* default: ok on success, otherwise ErrorDTO with error
* uri:
* type: string
* default: username
* default: generated uri
* subdomain:
* type: string
* default: subdomain or null
@@ -59,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;
if (insertedLink.length === 1) {
result.id = insertedLink[0].id;
result.exists = false;
} else {
result.id = insertedLink[0].id;
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;
if (insertedLink.length === 1) {
result.id = insertedLink[0].id;
result.exists = false;
} else {
result.id = insertedLink[0].id;
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

@@ -75,6 +75,16 @@ export class UserService {
}
/**
* Finds a User by it's identifier.
*
* @param {number} id The identifier
* @return {(Promise<User|null>)} User (or null if not found)
*/
async findById(id: number): Promise<User | null> {
return await this.userRepo.findOneBy({id});
}
/**
* Counts all the user entities in DB.
* Used to tell whether the next created account should be an admin

View File

@@ -1,8 +1,5 @@
import * as dotenv from 'dotenv';
dotenv.config({ quiet: true });
import * as env from './env';
let cors = require('cors');
import { getEnvString } from './jwt';
/**
* Returns user-trusted origins from the .env file.
@@ -12,7 +9,7 @@ import { getEnvString } from './jwt';
*/
export function getTrustedOrigins(): string[] {
let trustedOrigins: string[] = ['http://localhost:6568'];
const configOriginsString: string | undefined = getEnvString('trustedOrigins', true);
const configOriginsString: string | undefined = env.getString('trustedOrigins', true);
// No config available.
if (configOriginsString === undefined) {

87
src/tools/env.ts Normal file
View File

@@ -0,0 +1,87 @@
import * as dotenv from 'dotenv';
dotenv.config({ quiet: true });
/**
* Get the environmental string 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 {(string|undefined)} The environment string.
*/
export function getString(
key: string,
isGlobal: boolean = true
): string | undefined {
let keyName: string = '';
if (isGlobal) {
// Global values are DECLARED_LIKE_THIS=...
for (let i: number = 0; i < key.length; i++) {
if (key[i].toLowerCase() === key[i]) {
// If is lowercase, skip.
keyName += key[i];
} else {
// If is uppercase, convert to snake case.
keyName += `_${key[i].toLowerCase()}`;
}
}
keyName = keyName.toUpperCase();
} else {
// Non-global keys are parsed as passed
keyName = key;
}
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;
};

View File

@@ -1,50 +1,21 @@
// Heavily based on:
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/utils/jwt.utils.ts
import * as dotenv from 'dotenv';
dotenv.config({ quiet: true });
import jwt from 'jsonwebtoken';
import { DEFAULT_TOKEN_LIFETIME } from '../schemas/authSchema';
import * as env from './env';
type JwtStatus = {
valid: boolean;
expired: boolean;
decoded: string | jwt.JwtPayload | null;
export type JwtDecoded = {
sub: number;
role: number;
iat: number;
exp: number;
};
/**
* Get the environmental string 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 {(string|undefined)} The environment string.
*/
export function getEnvString(
key: string,
isGlobal: boolean = true
): string | undefined {
let keyName: string = '';
if (isGlobal) {
// Global values are DECLARED_LIKE_THIS=...
for (let i: number = 0; i < key.length; i++) {
if (key[i].toLowerCase() === key[i]) {
// If is lowercase, skip.
keyName += key[i];
} else {
// If is uppercase, convert to snake case.
keyName += `_${key[i].toLowerCase()}`;
}
}
keyName = keyName.toUpperCase();
} else {
// Non-global keys are parsed as passed
keyName = key;
}
return process.env[keyName];
}
export type JwtStatus = {
valid: boolean;
expired: boolean;
decoded: JwtDecoded | null; // null if decoding failed
};
/**
* Sign a JWT containing sub (number), role (number, 0/1), iat/exp (unix timestamp) claims.
@@ -66,7 +37,7 @@ export function signJwt(
// 'base64'
// ).toString('utf8');
const secret: string = getEnvString(keyName, true)!;
const secret: string = env.getString(keyName, true)!;
// Use the default expiration time of 24 hours.
if (options === undefined)
@@ -88,7 +59,7 @@ export function signJwt(
*/
export function verifyJwt(
token: string,
keyName: 'accessTokenPublicKey' | 'refreshTokenPublicKey'
keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey'
): JwtStatus {
// refresh tokens aren't (yet) supported
@@ -97,21 +68,24 @@ export function verifyJwt(
// 'base64'
// ).toString('utf8');
const secret: string = getEnvString(keyName, true)!;
const secret: string = env.getString(keyName, true)!;
try {
const decoded: string | jwt.JwtPayload = jwt.verify(token, secret);
const decoded: jwt.JwtPayload | string = jwt.verify(token, secret);
// TODO: Can this be done better, smarter?
return {
valid: true,
expired: false,
decoded,
decoded: decoded as unknown as JwtDecoded
};
} catch (e: any) {
console.error('JWT verify error:', e);
return {
valid: false,
valid: e.message !== 'jwt malformed',
expired: e.message === 'jwt expired',
decoded: null,
decoded: null
};
}
}