feat: add link creation and lookup
finally has the bare minimum functionality to say that it works!
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kittyBE",
|
"name": "kittyBE",
|
||||||
"version": "0.0.0",
|
"version": "0.0.1",
|
||||||
"description": "Your go-to place for short and memorable URLs.",
|
"description": "Your go-to place for short and memorable URLs.",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
77
src/app.ts
77
src/app.ts
@@ -1,27 +1,33 @@
|
|||||||
import * as dotenv from 'dotenv';
|
|
||||||
dotenv.config({ quiet: true });
|
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { version } from '../package.json';
|
import { version } from '../package.json';
|
||||||
import { AppDataSource } from './data-source'
|
import { AppDataSource } from './data-source'
|
||||||
|
import { Link } from './entities/Link';
|
||||||
import inferUser from './middleware/inferUser';
|
import inferUser from './middleware/inferUser';
|
||||||
import miscRouter from './routes/miscRoutes';
|
import miscRouter from './routes/miscRoutes';
|
||||||
import userRouter from './routes/userRoutes';
|
import userRouter from './routes/userRoutes';
|
||||||
import linkRouter from './routes/linkRoutes';
|
import linkRouter from './routes/linkRoutes';
|
||||||
import { getCorsConfig } from './tools/cors';
|
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 () => {
|
AppDataSource.initialize().then(async () => {
|
||||||
|
|
||||||
await AppDataSource.runMigrations();
|
await AppDataSource.runMigrations();
|
||||||
|
|
||||||
const app: express.Express = express();
|
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(express.json());
|
||||||
app.use(getCorsConfig());
|
app.use(getCorsConfig());
|
||||||
app.use(inferUser);
|
app.use(inferUser);
|
||||||
app.use(miscRouter, userRouter, linkRouter);
|
app.use(miscRouter, userRouter, linkRouter);
|
||||||
|
|
||||||
if (process.env['DEBUG'] === 'true') {
|
if (env.getBool('debug', true)) {
|
||||||
const swaggerJsdocOpts = {
|
const swaggerJsdocOpts = {
|
||||||
failOnErrors: true,
|
failOnErrors: true,
|
||||||
definition: {
|
definition: {
|
||||||
@@ -67,16 +73,65 @@ AppDataSource.initialize().then(async () => {
|
|||||||
|
|
||||||
// Handle 404s
|
// Handle 404s
|
||||||
// https://stackoverflow.com/a/9802006
|
// https://stackoverflow.com/a/9802006
|
||||||
app.use(function(req: express.Request, res: express.Response) {
|
app.use(async function(req: express.Request, res: express.Response) {
|
||||||
res.status(404);
|
|
||||||
|
|
||||||
if (req.accepts('json')) {
|
// Check if host header seems right
|
||||||
res.json({ status: 'error', error: 'Not found' });
|
try {
|
||||||
return;
|
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))
|
}).catch(error => console.log(error))
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { Link } from '../entities/Link';
|
|||||||
import { User } from '../entities/User';
|
import { User } from '../entities/User';
|
||||||
import * as ms from '../schemas/miscSchema';
|
import * as ms from '../schemas/miscSchema';
|
||||||
import * as ls from '../schemas/linkSchema';
|
import * as ls from '../schemas/linkSchema';
|
||||||
import { LinkService } from '../services/linkService';
|
import { LinkService, IdResponse } from '../services/linkService';
|
||||||
import { UserService } from '../services/userService';
|
import { UserService } from '../services/userService';
|
||||||
import * as env from '../tools/env';
|
import * as env from '../tools/env';
|
||||||
|
import * as jwt from '../tools/jwt';
|
||||||
import { generateSentenceString, generateShortString } from '../tools/wordlist';
|
import { generateSentenceString, generateShortString } from '../tools/wordlist';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,3 +78,128 @@ export async function generateSentenceLinkHandler(
|
|||||||
return res.status(200)
|
return res.status(200)
|
||||||
.send(userResponse);
|
.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,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import validateSchema from '../tools/validateSchema';
|
import validateSchema from '../tools/validateSchema';
|
||||||
import * as lc from '../controllers/linkController';
|
import * as lc from '../controllers/linkController';
|
||||||
import * as ls from '../schemas/linkSchema';
|
import * as ls from '../schemas/linkSchema';
|
||||||
|
import requireUser from '../middleware/requireUser';
|
||||||
|
|
||||||
const linkRouter = Router();
|
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);
|
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;
|
export default linkRouter;
|
||||||
@@ -62,3 +62,84 @@ export type LinkResponseDTO = {
|
|||||||
subdomain?: string | null; // null when server does not support generating subdomains
|
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
|
* @openapi
|
||||||
@@ -24,3 +25,8 @@ export type ErrorDTO = {
|
|||||||
error: string;
|
error: string;
|
||||||
code?: string | undefined;
|
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 { Link } from '../entities/Link';
|
||||||
import { User } from '../entities/User';
|
import { User } from '../entities/User';
|
||||||
import { AppDataSource } from '../data-source';
|
import { AppDataSource } from '../data-source';
|
||||||
import { getEnvString } from '../tools/jwt';
|
import * as env from '../tools/env';
|
||||||
|
import { IsNull, LessThan } from 'typeorm';
|
||||||
|
|
||||||
export type IdResponse = {
|
export type IdResponse = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,15 +14,15 @@ export class LinkService {
|
|||||||
linkRepo = this.dataSource.getRepository(Link);
|
linkRepo = this.dataSource.getRepository(Link);
|
||||||
|
|
||||||
// Retrieve config to check whether subdomains are allowed
|
// 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.
|
* Simply insert a new link entity anonymously.
|
||||||
*
|
*
|
||||||
* @param {Link} link The link to insert
|
* @param {Link} link The link to insert
|
||||||
*/
|
*/
|
||||||
async addAnonymous(link: Link): Promise<IdResponse> {
|
async addIfNewAnonymous(link: Link): Promise<IdResponse> {
|
||||||
let result: IdResponse = { id: -1, exists: false };
|
let result: IdResponse = { id: -1, exists: true };
|
||||||
|
|
||||||
// Sanity check: don't allow for adding links
|
// Sanity check: don't allow for adding links
|
||||||
// with subdomains if server has it disabled.
|
// with subdomains if server has it disabled.
|
||||||
@@ -33,16 +34,17 @@ export class LinkService {
|
|||||||
|
|
||||||
// Then get new link's ID
|
// Then get new link's ID
|
||||||
const insertedLink: Link[] = await this.linkRepo.findBy({
|
const insertedLink: Link[] = await this.linkRepo.findBy({
|
||||||
shortUri: link.shortUri,
|
// Add subdomain if used, https://stackoverflow.com/a/69151874
|
||||||
fullUrl: link.fullUrl
|
subdomain: link.subdomain ?? IsNull(),
|
||||||
|
shortUri: link.shortUri
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return appropriate id (or error)
|
// Return appropriate id (or error)
|
||||||
if (insertedLink.length !== 1) {
|
if (insertedLink.length === 1) {
|
||||||
result.id = -2;
|
|
||||||
result.exists = false;
|
|
||||||
} else {
|
|
||||||
result.id = insertedLink[0].id;
|
result.id = insertedLink[0].id;
|
||||||
|
result.exists = false;
|
||||||
|
} else {
|
||||||
|
result.id = -2;
|
||||||
result.exists = true;
|
result.exists = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,31 +75,29 @@ export class LinkService {
|
|||||||
*/
|
*/
|
||||||
async addIfNew(link: Link, user: User): Promise<IdResponse> {
|
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,
|
// If no conflicts are found,
|
||||||
// proceed with creating a new link entry.
|
// proceed with creating a new link entry.
|
||||||
if (await this.canInsert(link)) {
|
if (await this.canInsert(link)) {
|
||||||
// Commit a transaction
|
// Commit a transaction
|
||||||
this.dataSource.transaction(async (t) => {
|
await this.dataSource.transaction(async (t) => {
|
||||||
link.author = user;
|
link.author = user;
|
||||||
user.links.push(link);
|
|
||||||
t.insert(Link, link);
|
t.insert(Link, link);
|
||||||
t.save(user);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Then get new link's ID
|
// Then get new link's ID
|
||||||
const insertedLink: Link[] = await this.linkRepo.findBy({
|
const insertedLink: Link[] = await this.linkRepo.findBy({
|
||||||
shortUri: link.shortUri,
|
subdomain: link.subdomain ?? IsNull(),
|
||||||
fullUrl: link.fullUrl
|
shortUri: link.shortUri
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return appropriate id (or error)
|
// Return appropriate id (or error)
|
||||||
if (insertedLink.length !== 1) {
|
if (insertedLink.length === 1) {
|
||||||
result.id = -2;
|
|
||||||
result.exists = false;
|
|
||||||
} else {
|
|
||||||
result.id = insertedLink[0].id;
|
result.id = insertedLink[0].id;
|
||||||
|
result.exists = false;
|
||||||
|
} else {
|
||||||
|
result.id = -2;
|
||||||
result.exists = true;
|
result.exists = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +128,97 @@ export class LinkService {
|
|||||||
// any possible subdomains.
|
// any possible subdomains.
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
shortUriExists = await this.linkRepo.existsBy({ shortUri: link.shortUri });
|
shortUriExists = await this.linkRepo.existsBy({
|
||||||
|
shortUri: link.shortUri,
|
||||||
|
subdomain: IsNull()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return !shortUriExists;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,53 @@ export function getString(
|
|||||||
return process.env[keyName];
|
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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user