Compare commits
4 Commits
f86630c51e
...
d7f4006698
| Author | SHA1 | Date | |
|---|---|---|---|
| d7f4006698 | |||
| c19a098b1c | |||
| ec5cedce5a | |||
| 4bf39c7fdf |
@@ -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": {
|
||||
|
||||
79
src/app.ts
79
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: {
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
41
src/middleware/requireUser.ts
Normal file
41
src/middleware/requireUser.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)/);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
87
src/tools/env.ts
Normal 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;
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user