Compare commits
15 Commits
f86630c51e
...
v0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| e0d8849bd1 | |||
| 429613c67e | |||
| 681555fef8 | |||
| 066b9884c2 | |||
| 9311cd3c96 | |||
| 89e6832e73 | |||
| 109f22c231 | |||
| c548abc9ed | |||
| 355338e397 | |||
| 518eeec8e8 | |||
| 413aa8994a | |||
| d7f4006698 | |||
| c19a098b1c | |||
| ec5cedce5a | |||
| 4bf39c7fdf |
@@ -1,3 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
*/.env
|
*/.env
|
||||||
*.md
|
build
|
||||||
|
node_modules
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ FROM node:24-trixie-slim AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
RUN npm ci && npm cache clean --force
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
FROM node:24-trixie-slim AS production
|
FROM node:24-trixie-slim AS production
|
||||||
|
|||||||
70
README.md
70
README.md
@@ -1,2 +1,72 @@
|
|||||||
# kittyBE
|
# kittyBE
|
||||||
|
*Back-end for the [KittyURL](https://gitea.7o7.cx/kittyteam/kittyurl) project -- create short and memorable URLs with ease!*
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
Provide endpoints for:
|
||||||
|
- account management (`/api/v1/user/*`),
|
||||||
|
- link management (`/api/v1/link/*`),
|
||||||
|
- authed link management (when a link is bound to a user, `/api/v1/authed/*`),
|
||||||
|
- user management (for admins only, `/api/v1/admin/*`),
|
||||||
|
- general info (`/api/v1/info/*`),
|
||||||
|
|
||||||
|
KittyBE should also integrate nicely with [kittyFE](https://gitea.7o7.cx/kittyteam/kittyFE) and be easily dockerizable.
|
||||||
|
|
||||||
|
## Running kittyBE
|
||||||
|
KittyURL has been verified to work on Node 18.20+ and PostgreSQL 16.11+.
|
||||||
|
### On bare metal
|
||||||
|
Running the back-end is as simple as:
|
||||||
|
|
||||||
|
- Installing the dependencies:
|
||||||
|
- To just download the required dependencies:
|
||||||
|
```sh
|
||||||
|
npm i
|
||||||
|
```
|
||||||
|
- To install an exact copy of all of the dependencies:
|
||||||
|
```sh
|
||||||
|
npm ci
|
||||||
|
```
|
||||||
|
- Copying the .env.default file to .env, and customizing it to own preferences.
|
||||||
|
|
||||||
|
**Example:** Say, you want to add a domain to the trusted CORS origins list. To do so, your .env file in your editor of choice and append a comma (`,`) with the origin you want to add (say, `http://example.com`). Your .env file might then look as follows: `TRUSTED_ORIGINS=http://localhost:6568,http://example.com`.
|
||||||
|
|
||||||
|
**Important:** Make sure to change the `ACCESS_TOKEN_PRIVATE_KEY` variable to something secure, as this secret value will be used to generate user sessions. **Setting a weak key will allow attackers to potentially bruteforce your secret and forge user tokens!**
|
||||||
|
- Pasting your wordlist file into `src/tools/wordlist.ts`.
|
||||||
|
|
||||||
|
No wordlist file exists by default in `src/tools/wordlist.ts`. This is because wordlists were meant to be as modular as possible (with the philosophy of "bring your own wordlist"). If you leave that as-is, you'll run into runtime errors.
|
||||||
|
|
||||||
|
However, if you don't want to provide your own wordlist, and just want to get up and running as fast as possible, you're free to use the provided sample `wordlist.example-large.ts` file. Just copy it into `src/tools/wordlist.ts`:
|
||||||
|
```sh
|
||||||
|
cp wordlist.example-large.ts src/tools/wordlist.ts
|
||||||
|
```
|
||||||
|
- Launching the web server:
|
||||||
|
```sh
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
- And... that's it!
|
||||||
|
Now view your instance at http://localhost:6567, and -- if you've set the DEBUG flag in your `.env` file to `true` -- you can also visit http://localhost:6567/kttydocs/ for Swagger documentation.
|
||||||
|
|
||||||
|
### Using Docker
|
||||||
|
A Docker image is built for every release of kittyBE and [kittyFE](https://gitea.7o7.cx/kittyteam/kittyFE). For more instructions on how to run the project with Docker, please refer to the [kittyurl repository](https://gitea.7o7.cx/kittyteam/kittyurl) (which contains a sample [docker-compose.yaml file](https://gitea.7o7.cx/kittyteam/kittyurl/src/branch/master/docker-compose.yaml), as well as it's own .env file).
|
||||||
|
|
||||||
|
## Wordlists
|
||||||
|
You're free to provide your own wordlist file by pasting it into `src/tools/wordlist.ts`. For an example of how a wordlist file should look like, see `wordlist.example-large.ts`, and pay attention to the methods it exports.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
Two supplementary scripts have been provided for aid in troubleshooting database-related errors.
|
||||||
|
- Run pending migrations on your database
|
||||||
|
In a rare case, when you need to run the migrations before launching the server (as it will try running pending migrations on every launch), use:
|
||||||
|
```sh
|
||||||
|
npm run pendingMigration
|
||||||
|
```
|
||||||
|
|
||||||
|
- Issue a new migration
|
||||||
|
During development it might be necessary to issue new migrations. To do that, use:
|
||||||
|
```sh
|
||||||
|
# assuming you're in the base project directory
|
||||||
|
npm run newMigration ./src/migrations/myMigrationName
|
||||||
|
```
|
||||||
|
where `myMigrationName` is the name of your migration.
|
||||||
|
|
||||||
|
**Important:** TypeORM uses the state of your connected database when diffing for changes, unlike some other solutions, which take past migrations into consideration.
|
||||||
|
|
||||||
|
**Note:** If using other relational database than Postgres, make sure to do the due diligence of researching how to enable bigint support for your database driver. No other database type than Postgres has been tested.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "kittyBE",
|
"name": "kittyBE",
|
||||||
"version": "0.0.0",
|
"version": "0.0.3",
|
||||||
"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": {
|
||||||
|
|||||||
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 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: {
|
||||||
@@ -50,7 +56,7 @@ AppDataSource.initialize().then(async () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
security: [
|
security: [
|
||||||
{ bearerAuth: [] }
|
{ BearerJWT: [] }
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
apis: ['./src/routes/*.ts', './src/schemas/*.ts']
|
apis: ['./src/routes/*.ts', './src/schemas/*.ts']
|
||||||
@@ -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'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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, '').slice(0, -1) || null; // slice() to remove trailing dot
|
||||||
|
|
||||||
|
// 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|||||||
@@ -188,3 +188,43 @@ export async function createUserHandler(
|
|||||||
return res.status(201)
|
return res.status(201)
|
||||||
.send(userResponse);
|
.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 { 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 jwt from '../tools/jwt';
|
import * as jwt from '../tools/jwt';
|
||||||
import { generateSentenceString, generateShortString } from '../tools/wordlist';
|
import { generateSentenceString, generateShortString } from '../tools/wordlist';
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export async function generateShortLinkHandler(
|
|||||||
);
|
);
|
||||||
let generatedSubdomain: string | null = null;
|
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]');
|
generatedSubdomain = generateSentenceString('[subdomain]');
|
||||||
|
|
||||||
const userResponse: ls.LinkResponseDTO = {
|
const userResponse: ls.LinkResponseDTO = {
|
||||||
@@ -65,7 +66,7 @@ export async function generateSentenceLinkHandler(
|
|||||||
let generatedSentenceString: string = generateSentenceString();
|
let generatedSentenceString: string = generateSentenceString();
|
||||||
let generatedSubdomain: string | null = null;
|
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]');
|
generatedSubdomain = generateSentenceString('[subdomain]');
|
||||||
|
|
||||||
const userResponse: ls.LinkResponseDTO = {
|
const userResponse: ls.LinkResponseDTO = {
|
||||||
@@ -77,3 +78,127 @@ 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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -15,4 +15,5 @@ export const AppDataSource = new DataSource({
|
|||||||
entities: [__dirname + '/entities/*.ts'],
|
entities: [__dirname + '/entities/*.ts'],
|
||||||
migrations: [__dirname + '/migrations/*.ts'],
|
migrations: [__dirname + '/migrations/*.ts'],
|
||||||
subscribers: [],
|
subscribers: [],
|
||||||
|
parseInt8: true // https://github.com/typeorm/typeorm/issues/9341#issuecomment-1268986627
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/deserializeUser.ts
|
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/deserializeUser.ts
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { verifyJwt } from '../tools/jwt';
|
import * as jwt from '../tools/jwt';
|
||||||
|
|
||||||
const inferUser = async (
|
const inferUser = async (
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -17,12 +17,9 @@ const inferUser = async (
|
|||||||
|
|
||||||
if (!accessToken) return next();
|
if (!accessToken) return next();
|
||||||
|
|
||||||
const { decoded } = verifyJwt(accessToken, 'accessTokenPublicKey');
|
const token = jwt.verifyJwt(accessToken, 'accessTokenPrivateKey');
|
||||||
|
if (token) {
|
||||||
// console.log('decoded user:', decoded);
|
res.locals.user = token;
|
||||||
|
|
||||||
if (decoded) {
|
|
||||||
res.locals.user = decoded;
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
38
src/middleware/requireAdmin.ts
Normal file
38
src/middleware/requireAdmin.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ErrorDTO } from '../schemas/miscSchema';
|
||||||
|
import * as jwt from '../tools/jwt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if user has administrative privileges.
|
||||||
|
*
|
||||||
|
* This needs to happen AFTER ensuring this is not a guest session.
|
||||||
|
* So: use requireUser first, and after that requireAdmin to enforce
|
||||||
|
* admin privilege requirement.
|
||||||
|
*
|
||||||
|
* @param {Request} req The request
|
||||||
|
* @param {Response} res The resource
|
||||||
|
* @param {(Function|NextFunction)} next The next
|
||||||
|
* @return {any} Next function on success, unauthorized error otherwise
|
||||||
|
*/
|
||||||
|
const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const user: jwt.JwtStatus = res.locals.user;
|
||||||
|
let error: ErrorDTO | null = null;
|
||||||
|
|
||||||
|
// Check if role is set to 1 (1 = admin, 0 = standard user).
|
||||||
|
if (user.decoded?.role !== 1)
|
||||||
|
error = {
|
||||||
|
status: 'error',
|
||||||
|
error: 'Unauthorized, admin access required',
|
||||||
|
code: 'unauthorized_non_admin'
|
||||||
|
};
|
||||||
|
|
||||||
|
// It is? Send 401 unauthorized.
|
||||||
|
if (error !== null)
|
||||||
|
return res.status(401)
|
||||||
|
.send(error);
|
||||||
|
|
||||||
|
// Otherwise jump to next endpoint.
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default requireAdmin;
|
||||||
54
src/middleware/requireUser.ts
Normal file
54
src/middleware/requireUser.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ErrorDTO } from '../schemas/miscSchema';
|
||||||
|
import * as jwt from '../tools/jwt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if user is singed in.
|
||||||
|
* Returns 401 when user is unauthorized.
|
||||||
|
*
|
||||||
|
* To check if user is an admin, chain requireUser and requireAdmin together.
|
||||||
|
* So: use requireUser first, and after that requireAdmin to enforce
|
||||||
|
* admin privilege requirement.
|
||||||
|
*
|
||||||
|
* @param {Request} req The request
|
||||||
|
* @param {Response} res The resource
|
||||||
|
* @param {(Function|NextFunction)} next The next
|
||||||
|
* @return {any} Next function on success, unauthorized error otherwise
|
||||||
|
*/
|
||||||
|
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 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,44 @@ 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. <br/>
|
||||||
|
* <b>Note:</b> This endpoint's functionality differs depending on the user info,
|
||||||
|
* which means guests will be treated differently from authenticated users.
|
||||||
|
* tags: [Link]
|
||||||
|
* summary: "[AUTHED?] 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;
|
||||||
@@ -2,6 +2,7 @@ import { Router } from 'express';
|
|||||||
import validateSchema from '../tools/validateSchema';
|
import validateSchema from '../tools/validateSchema';
|
||||||
import * as ac from '../controllers/authController';
|
import * as ac from '../controllers/authController';
|
||||||
import * as as from '../schemas/authSchema';
|
import * as as from '../schemas/authSchema';
|
||||||
|
import requireUser from '../middleware/requireUser';
|
||||||
|
|
||||||
const userRouter = Router();
|
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);
|
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;
|
export default userRouter;
|
||||||
@@ -80,3 +80,33 @@ export type UserInfoDTO = {
|
|||||||
ttl: number | null;
|
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';
|
import z from 'zod';
|
||||||
|
|
||||||
|
// GET /api/v1/link/short
|
||||||
const shortLinkRequestSchemaQuery = z.object({
|
const shortLinkRequestSchemaQuery = z.object({
|
||||||
// https://zod.dev/v4?id=stringbool
|
// https://zod.dev/v4?id=stringbool
|
||||||
length: z.coerce
|
length: z.coerce
|
||||||
@@ -20,6 +21,7 @@ export const shortLinkRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ShortLinkRequestDTO = z.TypeOf<typeof shortLinkRequestSchema>;
|
export type ShortLinkRequestDTO = z.TypeOf<typeof shortLinkRequestSchema>;
|
||||||
|
|
||||||
|
// GET /api/v1/link/fromWordlist
|
||||||
const sentenceLinkRequestSchemaQuery = z.object({
|
const sentenceLinkRequestSchemaQuery = z.object({
|
||||||
// https://zod.dev/v4?id=stringbool
|
// https://zod.dev/v4?id=stringbool
|
||||||
withSubdomain: z.stringbool('WithSubdomain must be a boolean')
|
withSubdomain: z.stringbool('WithSubdomain must be a boolean')
|
||||||
@@ -31,6 +33,7 @@ export const sentenceLinkRequestSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type SentenceLinkRequestDTO = z.TypeOf<typeof sentenceLinkRequestSchema>;
|
export type SentenceLinkRequestDTO = z.TypeOf<typeof sentenceLinkRequestSchema>;
|
||||||
|
|
||||||
|
// response for both /api/v1/link/short and /api/v1/link/fromWordlist
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -48,7 +51,7 @@ export type SentenceLinkRequestDTO = z.TypeOf<typeof sentenceLinkRequestSchema>;
|
|||||||
* default: ok on success, otherwise ErrorDTO with error
|
* default: ok on success, otherwise ErrorDTO with error
|
||||||
* uri:
|
* uri:
|
||||||
* type: string
|
* type: string
|
||||||
* default: username
|
* default: generated uri
|
||||||
* subdomain:
|
* subdomain:
|
||||||
* type: string
|
* type: string
|
||||||
* default: subdomain or null
|
* default: subdomain or null
|
||||||
@@ -59,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|healthcheck|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.id = insertedLink[0].id;
|
||||||
result.exists = false;
|
result.exists = false;
|
||||||
} else {
|
} else {
|
||||||
result.id = insertedLink[0].id;
|
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.id = insertedLink[0].id;
|
||||||
result.exists = false;
|
result.exists = false;
|
||||||
} else {
|
} else {
|
||||||
result.id = insertedLink[0].id;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
* Counts all the user entities in DB.
|
||||||
* Used to tell whether the next created account should be an admin
|
* Used to tell whether the next created account should be an admin
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import * as dotenv from 'dotenv';
|
import * as env from './env';
|
||||||
dotenv.config({ quiet: true });
|
|
||||||
|
|
||||||
let cors = require('cors');
|
let cors = require('cors');
|
||||||
import { getEnvString } from './jwt';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns user-trusted origins from the .env file.
|
* Returns user-trusted origins from the .env file.
|
||||||
@@ -12,7 +9,7 @@ import { getEnvString } from './jwt';
|
|||||||
*/
|
*/
|
||||||
export function getTrustedOrigins(): string[] {
|
export function getTrustedOrigins(): string[] {
|
||||||
let trustedOrigins: string[] = ['http://localhost:6568'];
|
let trustedOrigins: string[] = ['http://localhost:6568'];
|
||||||
const configOriginsString: string | undefined = getEnvString('trustedOrigins', true);
|
const configOriginsString: string | undefined = env.getString('trustedOrigins', true);
|
||||||
|
|
||||||
// No config available.
|
// No config available.
|
||||||
if (configOriginsString === undefined) {
|
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:
|
// Heavily based on:
|
||||||
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/utils/jwt.utils.ts
|
// 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 jwt from 'jsonwebtoken';
|
||||||
import { DEFAULT_TOKEN_LIFETIME } from '../schemas/authSchema';
|
import { DEFAULT_TOKEN_LIFETIME } from '../schemas/authSchema';
|
||||||
|
import * as env from './env';
|
||||||
|
|
||||||
type JwtStatus = {
|
export type JwtDecoded = {
|
||||||
valid: boolean;
|
sub: number;
|
||||||
expired: boolean;
|
role: number;
|
||||||
decoded: string | jwt.JwtPayload | null;
|
iat: number;
|
||||||
|
exp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
export type JwtStatus = {
|
||||||
* Get the environmental string from .env.
|
valid: boolean;
|
||||||
* Supports rewriting names to UPPER_SNAKE_CASE if isGlobal is set.
|
expired: boolean;
|
||||||
*
|
decoded: JwtDecoded | null; // null if decoding failed
|
||||||
* @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];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign a JWT containing sub (number), role (number, 0/1), iat/exp (unix timestamp) claims.
|
* Sign a JWT containing sub (number), role (number, 0/1), iat/exp (unix timestamp) claims.
|
||||||
@@ -66,7 +37,7 @@ export function signJwt(
|
|||||||
// 'base64'
|
// 'base64'
|
||||||
// ).toString('utf8');
|
// ).toString('utf8');
|
||||||
|
|
||||||
const secret: string = getEnvString(keyName, true)!;
|
const secret: string = env.getString(keyName, true)!;
|
||||||
|
|
||||||
// Use the default expiration time of 24 hours.
|
// Use the default expiration time of 24 hours.
|
||||||
if (options === undefined)
|
if (options === undefined)
|
||||||
@@ -88,7 +59,7 @@ export function signJwt(
|
|||||||
*/
|
*/
|
||||||
export function verifyJwt(
|
export function verifyJwt(
|
||||||
token: string,
|
token: string,
|
||||||
keyName: 'accessTokenPublicKey' | 'refreshTokenPublicKey'
|
keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey'
|
||||||
): JwtStatus {
|
): JwtStatus {
|
||||||
|
|
||||||
// refresh tokens aren't (yet) supported
|
// refresh tokens aren't (yet) supported
|
||||||
@@ -97,21 +68,24 @@ export function verifyJwt(
|
|||||||
// 'base64'
|
// 'base64'
|
||||||
// ).toString('utf8');
|
// ).toString('utf8');
|
||||||
|
|
||||||
const secret: string = getEnvString(keyName, true)!;
|
const secret: string = env.getString(keyName, true)!;
|
||||||
|
|
||||||
try {
|
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 {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
expired: false,
|
expired: false,
|
||||||
decoded,
|
decoded: decoded as unknown as JwtDecoded
|
||||||
};
|
};
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('JWT verify error:', e);
|
console.error('JWT verify error:', e);
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: e.message !== 'jwt malformed',
|
||||||
expired: e.message === 'jwt expired',
|
expired: e.message === 'jwt expired',
|
||||||
decoded: null,
|
decoded: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"es2021"
|
"es2021"
|
||||||
],
|
],
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"target": "es2021",
|
"target": "esnext",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"outDir": "./build",
|
"outDir": "./build",
|
||||||
|
|||||||
Reference in New Issue
Block a user