Settings System (#46)

* feat(api): settings system

Also includes /auth/me endpoint for ticket ch76 and OpenAPI 3.0 compatibility for ch77

* refactor(api): remove unused imports
This commit is contained in:
sct
2020-09-03 19:17:15 +09:00
committed by GitHub
parent af95c2fb47
commit 5d46f8d76d
17 changed files with 1076 additions and 31 deletions

View File

@@ -8,6 +8,10 @@ import {
@Entity()
export class User {
public static filterMany(users: User[]): Partial<User>[] {
return users.map((u) => u.filter());
}
@PrimaryGeneratedColumn()
public id: number;
@@ -15,7 +19,7 @@ export class User {
public email: string;
@Column({ nullable: true })
public plexToken: string;
public plexToken?: string;
@CreateDateColumn()
public createdAt: Date;
@@ -26,4 +30,13 @@ export class User {
constructor(init?: Partial<User>) {
Object.assign(this, init);
}
public filter(): Partial<User> {
return {
id: this.id,
email: this.email,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
}
}

View File

@@ -1,12 +1,19 @@
import express from 'express';
import express, { Request, Response, NextFunction } from 'express';
import next from 'next';
import path from 'path';
import { createConnection, getRepository } from 'typeorm';
import routes from './routes';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import { TypeormStore } from 'connect-typeorm/out';
import YAML from 'yamljs';
import swaggerUi from 'swagger-ui-express';
import { OpenApiValidator } from 'express-openapi-validator';
import { Session } from './entity/Session';
import { getSettings } from './lib/settings';
const API_SPEC_PATH = path.join(__dirname, 'overseerr-api.yml');
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
@@ -16,7 +23,10 @@ createConnection();
app
.prepare()
.then(() => {
.then(async () => {
// Load Settings
getSettings().load();
const server = express();
server.use(cookieParser());
server.use(bodyParser.json());
@@ -35,8 +45,41 @@ app
}).connect(sessionRespository),
})
);
server.use('/api', routes);
const apiDocs = YAML.load(API_SPEC_PATH);
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDocs));
await new OpenApiValidator({
apiSpec: API_SPEC_PATH,
validateRequests: true,
validateResponses: true,
}).install(server);
/**
* This is a workaround to convert dates to strings before they are validated by
* OpenAPI validator. Otherwise, they are treated as objects instead of strings
* and response validation will fail
*/
server.use((req, res, next) => {
const original = res.json;
res.json = function jsonp(json) {
return original.call(this, JSON.parse(JSON.stringify(json)));
};
next();
});
server.use('/api/v1', routes);
server.get('*', (req, res) => handle(req, res));
server.use(
(
err: { status: number; message: string; errors: string[] },
req: Request,
res: Response,
_next: NextFunction
) => {
// format error
res.status(err.status || 500).json({
message: err.message,
errors: err.errors,
});
}
);
const port = Number(process.env.PORT) || 3000;
server.listen(port, (err) => {

149
server/lib/settings.ts Normal file
View File

@@ -0,0 +1,149 @@
import fs from 'fs';
import path from 'path';
interface Library {
id: string;
name: string;
enabled: boolean;
}
interface PlexSettings {
name: string;
machineId: string;
ip: string;
port: number;
libraries: Library[];
}
interface DVRSettings {
id: number;
name: string;
hostname: string;
port: number;
apiKey: string;
useSsl: boolean;
baseUrl?: string;
activeProfile: string;
activeDirectory: string;
is4k: boolean;
}
export interface RadarrSettings extends DVRSettings {
minimumAvailability: string;
}
export interface SonarrSettings extends DVRSettings {
activeAnimeProfile?: string;
activeAnimeDirectory?: string;
enableSeasonFolders: boolean;
}
interface MainSettings {
apiKey: string;
}
interface AllSettings {
main: MainSettings;
plex: PlexSettings;
radarr: RadarrSettings[];
sonarr: SonarrSettings[];
}
const SETTINGS_PATH = path.join(__dirname, '../../config/settings.json');
class Settings {
private data: AllSettings;
constructor(initialSettings?: AllSettings) {
this.data = {
main: {
apiKey: 'temp',
},
plex: {
name: 'Main Server',
ip: '127.0.0.1',
port: 32400,
machineId: '',
libraries: [],
},
radarr: [],
sonarr: [],
};
if (initialSettings) {
Object.assign<AllSettings, AllSettings>(this.data, initialSettings);
}
}
get main(): MainSettings {
return this.data.main;
}
set main(data: MainSettings) {
this.data.main = data;
}
get plex(): PlexSettings {
return this.data.plex;
}
set plex(data: PlexSettings) {
this.data.plex = data;
}
get radarr(): RadarrSettings[] {
return this.data.radarr;
}
set radarr(data: RadarrSettings[]) {
this.data.radarr = data;
}
get sonarr(): SonarrSettings[] {
return this.data.sonarr;
}
set sonarr(data: SonarrSettings[]) {
this.data.sonarr = data;
}
/**
* Settings Load
*
* This will load settings from file unless an optional argument of the object structure
* is passed in.
* @param overrideSettings If passed in, will override all existing settings with these
* values
*/
public load(overrideSettings?: AllSettings): AllSettings {
if (overrideSettings) {
this.data = overrideSettings;
return this.data;
}
if (!fs.existsSync(SETTINGS_PATH)) {
this.save();
}
const data = fs.readFileSync(SETTINGS_PATH, 'utf-8');
if (data) {
this.data = JSON.parse(data);
}
return this.data;
}
public save(): void {
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(this.data, undefined, ' '));
}
}
let settings: Settings | undefined;
export const getSettings = (initialSettings?: AllSettings): Settings => {
if (!settings) {
settings = new Settings(initialSettings);
}
return settings;
};
export default Settings;

View File

@@ -1,6 +1,5 @@
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import { Middleware } from '../types/express';
export const checkUser: Middleware = async (req, _res, next) => {
if (req.session?.userId) {

482
server/overseerr-api.yml Normal file
View File

@@ -0,0 +1,482 @@
openapi: '3.0.2'
info:
title: 'Overseerr API'
version: '1.0.0'
servers:
- url: /api/v1
components:
schemas:
User:
type: object
properties:
id:
type: integer
example: 1
email:
type: string
example: 'hey@itsme.com'
plexToken:
type: string
createdAt:
type: string
example: '2020-09-02T05:02:23.000Z'
updatedAt:
type: string
example: '2020-09-02T05:02:23.000Z'
required:
- id
- email
- createdAt
- updatedAt
MainSettings:
type: object
properties:
apiKey:
type: string
example: 'anapikey'
required:
- apiKey
PlexLibrary:
type: object
properties:
id:
type: string
name:
type: string
example: Movies
enabled:
type: boolean
example: false
required:
- id
- name
- enabled
PlexSettings:
type: object
properties:
name:
type: string
example: 'Main Server'
machineId:
type: string
example: '1234-1234-1234-1234'
ip:
type: string
example: '127.0.0.1'
port:
type: number
example: 32400
libraries:
type: array
items:
$ref: '#/components/schemas/PlexLibrary'
required:
- name
- machineId
- ip
- port
RadarrSettings:
type: object
properties:
id:
type: number
example: 0
readOnly: true
name:
type: string
example: 'Radarr Main'
hostname:
type: string
example: '127.0.0.1'
port:
type: number
example: 7878
apiKey:
type: string
example: 'exampleapikey'
useSsl:
type: boolean
example: false
baseUrl:
type: string
activeProfile:
type: string
example: '1080p'
activeDirectory:
type: string
example: '/movies'
is4k:
type: boolean
example: false
minimumAvailability:
type: string
example: 'In Cinema'
required:
- name
- hostname
- port
- apiKey
- useSsl
- activeProfile
- activeDirectory
- is4k
- minimumAvailability
SonarrSettings:
type: object
properties:
id:
type: number
example: 0
readOnly: true
name:
type: string
example: 'Sonarr Main'
hostname:
type: string
example: '127.0.0.1'
port:
type: number
example: 8989
apiKey:
type: string
example: 'exampleapikey'
useSsl:
type: boolean
example: false
baseUrl:
type: string
activeProfile:
type: string
example: '1080p'
activeDirectory:
type: string
example: '/movies'
activeAnimeProfile:
type: string
nullable: true
activeAnimeDirectory:
type: string
nullable: true
is4k:
type: boolean
example: false
enableSeasonFolders:
type: boolean
example: false
required:
- name
- hostname
- port
- apiKey
- useSsl
- activeProfile
- activeDirectory
- is4k
- enableSeasonFolders
AllSettings:
type: object
properties:
main:
$ref: '#/components/schemas/MainSettings'
plex:
$ref: '#/components/schemas/PlexSettings'
radarr:
type: array
items:
$ref: '#/components/schemas/RadarrSettings'
sonarr:
type: array
items:
$ref: '#/components/schemas/SonarrSettings'
required:
- main
- plex
- radarr
- sonarr
securitySchemes:
cookieAuth:
type: apiKey
name: connect.sid
in: cookie
paths:
/settings/main:
get:
summary: Returns main settings
description: Retreives all main settings in JSON format
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
post:
summary: Update main settings
description: Update current main settings with provided values
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/MainSettings'
/settings/plex:
get:
summary: Returns plex settings
description: Retrieves current Plex settings
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/PlexSettings'
post:
summary: Update plex settings
description: Update the current plex settings with provided values
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlexSettings'
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/PlexSettings'
/settings/radarr:
get:
summary: Get all radarr settings
description: Returns all radarr settings in a JSON array
tags:
- settings
responses:
'200':
description: 'Values were returned'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/RadarrSettings'
post:
summary: Create new radarr instance
description: Creates a new radarr instance from the request body
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RadarrSettings'
responses:
'201':
description: 'New Radarr instance created'
content:
application/json:
schema:
$ref: '#/components/schemas/RadarrSettings'
/settings/radarr/{radarrId}:
put:
summary: Update existing radarr instance
description: Updates an existing radarr instance with values from request body
tags:
- settings
parameters:
- in: path
name: radarrId
required: true
schema:
type: integer
description: Radarr instance ID
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RadarrSettings'
responses:
'200':
description: 'Radarr instance updated'
content:
application/json:
schema:
$ref: '#/components/schemas/RadarrSettings'
delete:
summary: Delete existing radarr instance
description: Deletes an existing radarr instance based on id parameter
tags:
- settings
parameters:
- in: path
name: radarrId
required: true
schema:
type: integer
description: Radarr Instance ID
responses:
'200':
description: 'Radarr instance updated'
content:
application/json:
schema:
$ref: '#/components/schemas/RadarrSettings'
/settings/sonarr:
get:
summary: Get all sonarr settings
description: Returns all sonarr settings in a JSON array
tags:
- settings
responses:
'200':
description: 'Values were returned'
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SonarrSettings'
post:
summary: Create new Sonarr instance
description: Creates a new Sonarr instance from the request body
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SonarrSettings'
responses:
'201':
description: 'New Sonarr instance created'
content:
application/json:
schema:
$ref: '#/components/schemas/SonarrSettings'
/settings/sonarr/{sonarrId}:
put:
summary: Update existing sonarr instance
description: Updates an existing sonarr instance with values from request body
tags:
- settings
parameters:
- in: path
name: sonarrId
required: true
schema:
type: integer
description: Sonarr instance ID
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SonarrSettings'
responses:
'200':
description: 'Sonarr instance updated'
content:
application/json:
schema:
$ref: '#/components/schemas/SonarrSettings'
delete:
summary: Delete existing sonarr instance
description: Deletes an existing sonarr instance based on id parameter
tags:
- settings
parameters:
- in: path
name: sonarrId
required: true
schema:
type: integer
description: Sonarr Instance ID
responses:
'200':
description: 'Sonarr instance updated'
content:
application/json:
schema:
$ref: '#/components/schemas/SonarrSettings'
/auth/me:
get:
summary: Returns the currently logged in user
description: Returns the currently logged in user
tags:
- auth
- users
responses:
'200':
description: Object containing the logged in user in JSON
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/auth/login:
post:
summary: Login using a plex auth token
description: Takes an `authToken` (plex token) to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main plex server, they will also have an account created, but without any permissions.
security: []
tags:
- auth
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
status:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
authToken:
type: string
required:
- authToken
/user:
get:
summary: Returns a list of all users
description: Requests all users and returns them in a large array
tags:
- users
responses:
'200':
description: A JSON array of all users
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/User'
security:
- cookieAuth: []

View File

@@ -2,9 +2,26 @@ import { Router } from 'express';
import { getRepository } from 'typeorm';
import { User } from '../entity/User';
import PlexTvAPI from '../api/plextv';
import { isAuthenticated } from '../middleware/auth';
const authRoutes = Router();
authRoutes.get('/me', isAuthenticated, async (req, res) => {
const userRepository = getRepository(User);
if (!req.user) {
return res.status(500).json({
status: 500,
error:
'Requsted user endpoint withuot valid authenticated user in session',
});
}
const user = await userRepository.findOneOrFail({
where: { id: req.user.id },
});
return res.status(200).json(user.filter());
});
authRoutes.post('/login', async (req, res) => {
const userRepository = getRepository(User);
const body = req.body as { authToken?: string };

View File

@@ -2,11 +2,13 @@ import { Router } from 'express';
import user from './user';
import authRoutes from './auth';
import { checkUser, isAuthenticated } from '../middleware/auth';
import settingsRoutes from './settings';
const router = Router();
router.use(checkUser);
router.use('/user', isAuthenticated, user);
router.use('/settings', isAuthenticated, settingsRoutes);
router.use('/auth', authRoutes);
router.get('/', (req, res) => {
@@ -16,8 +18,4 @@ router.get('/', (req, res) => {
});
});
router.all('*', (req, res) =>
res.status(404).json({ status: 404, message: '404 Not Found' })
);
export default router;

156
server/routes/settings.ts Normal file
View File

@@ -0,0 +1,156 @@
import { Router } from 'express';
import { getSettings, RadarrSettings, SonarrSettings } from '../lib/settings';
const settingsRoutes = Router();
settingsRoutes.get('/main', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.main);
});
settingsRoutes.post('/main', (req, res) => {
const settings = getSettings();
settings.main = req.body;
settings.save();
return res.status(200).json(settings.main);
});
settingsRoutes.get('/plex', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.plex);
});
settingsRoutes.post('/plex', (req, res) => {
const settings = getSettings();
settings.plex = req.body;
settings.save();
return res.status(200).json(settings.plex);
});
settingsRoutes.get('/radarr', (req, res) => {
const settings = getSettings();
res.status(200).json(settings.radarr);
});
settingsRoutes.post('/radarr', (req, res) => {
const settings = getSettings();
const newRadarr = req.body as RadarrSettings;
const lastItem = settings.radarr[settings.radarr.length - 1];
newRadarr.id = lastItem ? lastItem.id + 1 : 0;
settings.radarr = [...settings.radarr, newRadarr];
settings.save();
return res.status(201).json(newRadarr);
});
settingsRoutes.put<{ id: string }>('/radarr/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
settings.radarr[radarrIndex] = {
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
});
settingsRoutes.delete<{ id: string }>('/radarr/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.radarr.splice(radarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
settingsRoutes.get('/sonarr', (req, res) => {
const settings = getSettings();
res.status(200).json(settings.sonarr);
});
settingsRoutes.post('/sonarr', (req, res) => {
const settings = getSettings();
const newSonarr = req.body as SonarrSettings;
const lastItem = settings.sonarr[settings.sonarr.length - 1];
newSonarr.id = lastItem ? lastItem.id + 1 : 0;
settings.sonarr = [...settings.sonarr, newSonarr];
settings.save();
return res.status(201).json(newSonarr);
});
settingsRoutes.put<{ id: string }>('/sonarr/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
settings.sonarr[sonarrIndex] = {
...req.body,
id: Number(req.params.id),
} as SonarrSettings;
settings.save();
return res.status(200).json(settings.sonarr[sonarrIndex]);
});
settingsRoutes.delete<{ id: string }>('/sonarr/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.sonarr.splice(sonarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
export default settingsRoutes;

View File

@@ -9,7 +9,7 @@ router.get('/', async (req, res) => {
const users = await userRepository.find();
return res.status(200).json(users);
return res.status(200).json(User.filterMany(users));
});
export default router;

View File

@@ -6,8 +6,7 @@
"noEmit": false,
"strictPropertyInitialization": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"typeRoots": ["types"]
"emitDecoratorMetadata": true
},
"include": ["**/*.ts", "**/*.tsx"]
"include": ["**/*.ts"]
}

View File

@@ -12,10 +12,10 @@ declare global {
user?: User;
}
}
}
export type Middleware = <ParamsDictionary, any, any>(
req: Request,
res: Response,
next: NextFunction
) => Promise<void | NextFunction> | void | NextFunction;
export type Middleware = <ParamsDictionary, any, any>(
req: Request,
res: Response,
next: NextFunction
) => Promise<void | NextFunction> | void | NextFunction;
}