11 Commits

Author SHA1 Message Date
9311cd3c96 chore: release v0.0.2
All checks were successful
Build and push Docker image / build (push) Successful in 2m46s
Release new version / release (push) Successful in 26s
Update changelog / changelog (push) Successful in 24s
2026-01-07 23:06:21 +01:00
89e6832e73 Merge remote-tracking branch 'origin/master' 2026-01-07 23:04:42 +01:00
109f22c231 docs: add note to link shortening endpoint swagger doc 2026-01-07 23:04:35 +01:00
c548abc9ed docs: update readme
All checks were successful
Update changelog / changelog (push) Successful in 26s
2026-01-07 22:35:17 +01:00
355338e397 docs: add requireAdmin and docs for requireUser 2026-01-03 18:29:14 +01:00
518eeec8e8 fix: bigint type confusion
All checks were successful
Update changelog / changelog (push) Successful in 25s
2026-01-03 12:02:03 +01:00
413aa8994a fix: include healthcheck as part of protected urls
All checks were successful
Update changelog / changelog (push) Successful in 25s
2026-01-03 11:05:25 +01:00
d7f4006698 feat: add link creation and lookup
All checks were successful
Build and push Docker image / build (push) Successful in 2m50s
Release new version / release (push) Successful in 26s
Update changelog / changelog (push) Successful in 25s
finally has the bare minimum functionality to say that it works!
2026-01-03 10:51:59 +01:00
c19a098b1c feat: add sample endpoint to test JWT 2026-01-03 04:37:20 +01:00
ec5cedce5a fix: actually support JWT bearer authentication 2026-01-03 00:29:45 +01:00
4bf39c7fdf chore: offload retrieval of environment variables from jwt.ts to env.ts 2026-01-02 22:57:03 +01:00
20 changed files with 819 additions and 98 deletions

View File

@@ -1,2 +1,68 @@
# 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.

View File

@@ -1,6 +1,6 @@
{ {
"name": "kittyBE", "name": "kittyBE",
"version": "0.0.0", "version": "0.0.2",
"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": {

View File

@@ -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'
});
}
// 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))

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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
}) })

View File

@@ -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();
} }

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

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

View File

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

View File

@@ -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)/);

View File

@@ -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);
}
} }

View File

@@ -75,6 +75,16 @@ export class UserService {
} }
/**
* Finds a User by it's identifier.
*
* @param {number} id The identifier
* @return {(Promise<User|null>)} User (or null if not found)
*/
async findById(id: number): Promise<User | null> {
return await this.userRepo.findOneBy({id});
}
/** /**
* Counts all the user entities in DB. * 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

View File

@@ -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
View File

@@ -0,0 +1,87 @@
import * as dotenv from 'dotenv';
dotenv.config({ quiet: true });
/**
* Get the environmental string from .env.
* Supports rewriting names to UPPER_SNAKE_CASE if isGlobal is set.
*
* @param {string} key The key
* @param {boolean} [isGlobal=true] Indicates if global
* @return {(string|undefined)} The environment string.
*/
export function getString(
key: string,
isGlobal: boolean = true
): string | undefined {
let keyName: string = '';
if (isGlobal) {
// Global values are DECLARED_LIKE_THIS=...
for (let i: number = 0; i < key.length; i++) {
if (key[i].toLowerCase() === key[i]) {
// If is lowercase, skip.
keyName += key[i];
} else {
// If is uppercase, convert to snake case.
keyName += `_${key[i].toLowerCase()}`;
}
}
keyName = keyName.toUpperCase();
} else {
// Non-global keys are parsed as passed
keyName = key;
}
return process.env[keyName];
}
/**
* Get the environmental boolean from .env.
* Supports rewriting names to UPPER_SNAKE_CASE if isGlobal is set.
*
* @param {string} key The key
* @param {boolean} [isGlobal=true] Indicates if global
* @return {(boolean|undefined)} The environment boolean.
*/
export function getBool(
key: string,
isGlobal: boolean = true
): boolean | undefined {
const valueRead: string | undefined = getString(key, isGlobal);
if (valueRead === undefined) return undefined;
if (valueRead.toLowerCase() === 'true')
return true;
return false;
}
/**
* Processes public url, returning protocol, fqdn and path.
* proto://fqdn/path/ = fullPublicUrl
* @return {RewriteStrings} The rewrite strings.
*/
export function getRewriteStrings(): RewriteStrings {
const fullPublicUrl: string = getString('publicUrl', true)!;
const url = new URL(fullPublicUrl);
const proto = url.protocol.slice(0, -1); // https: -> https
const fqdn = url.host;
const path = url.pathname.replace(/\/+$/, '') + '/'; // /abc -> /abc/
const result: RewriteStrings = {
fullPublicUrl,
proto,
fqdn,
path
};
return result;
};
export type RewriteStrings = {
fullPublicUrl: string;
proto: string;
fqdn: string;
path: string;
};

View File

@@ -1,50 +1,21 @@
// Heavily based on: // 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
}; };
} }
} }

View File

@@ -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",