Initial commit with decorator router, and some basic subsonic api code
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import Router from "@koa/router";
|
||||
import Koa from "koa";
|
||||
|
||||
type Constructor<T = {}> = new (...args: any[]) => T;
|
||||
|
||||
type Route = {
|
||||
method: string;
|
||||
path: string;
|
||||
fn: any;
|
||||
};
|
||||
|
||||
export class BaseRouter {
|
||||
public router!: Router;
|
||||
|
||||
processRoutes(routes: Route[]) {
|
||||
for (const route of routes) switch (route.method) {
|
||||
case "get": this.router.get(route.path, route.fn); break;
|
||||
case "post": this.router.post(route.path, route.fn); break;
|
||||
case 'use': this.router.use(route.path, route.fn); break;
|
||||
}
|
||||
}
|
||||
|
||||
injectInto(app: Koa) {
|
||||
app.use(this.router.routes()).use(this.router.allowedMethods());
|
||||
}
|
||||
}
|
||||
|
||||
export function get(path: string) {
|
||||
return function getDecorator (originalMethod: any, context: ClassMethodDecoratorContext) {
|
||||
if (context.metadata) {
|
||||
if (!context.metadata.routes) {
|
||||
context.metadata.routes = [];
|
||||
}
|
||||
// @ts-ignore
|
||||
context.metadata.routes.push({method: 'get', path, fn: originalMethod});
|
||||
}
|
||||
return originalMethod;
|
||||
}
|
||||
}
|
||||
|
||||
export function post(path: string) {
|
||||
return function getDecorator (originalMethod: any, context: ClassMethodDecoratorContext) {
|
||||
if (context.metadata) {
|
||||
if (!context.metadata.routes) {
|
||||
context.metadata.routes = [];
|
||||
}
|
||||
// @ts-ignore
|
||||
context.metadata.routes.push({method: 'post', path, fn: originalMethod});
|
||||
}
|
||||
return originalMethod;
|
||||
}
|
||||
}
|
||||
|
||||
export function middleware(fn: any, path: string = '') {
|
||||
return function middlewareDecorator (constructor: any, context: ClassDecoratorContext) {
|
||||
if (context.metadata) {
|
||||
if (!context.metadata.routes) {
|
||||
context.metadata.routes = [];
|
||||
}
|
||||
// @ts-ignore
|
||||
context.metadata.routes.unshift({method: 'use', path, fn});
|
||||
}
|
||||
return constructor;
|
||||
}
|
||||
}
|
||||
|
||||
export function router(prefix: string) {
|
||||
return function routerDecorator<T extends Constructor>(constructor: T, context: ClassDecoratorContext<T>) {
|
||||
return class extends constructor {
|
||||
constructor(...args: any[]) {
|
||||
super(...args);
|
||||
if (this instanceof BaseRouter) {
|
||||
this.router = new Router({prefix});
|
||||
// @ts-ignore
|
||||
this.processRoutes(context.metadata?.routes || [] satisfies Route[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export enum SubsonicErrorCode {
|
||||
Generic = 0,
|
||||
RequiredParam = 10,
|
||||
ClientUpgradeRequired = 20,
|
||||
ServerUpgradeRequired = 30,
|
||||
InvalidAuth = 40,
|
||||
TokenAuthNotSupported = 41,
|
||||
SpecifiedAuthNotSupported = 42,
|
||||
ConflictingAuthSchemes = 43,
|
||||
InvalidApikey = 44,
|
||||
NotAuthorized = 50,
|
||||
NotFound = 70,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum SubsonicResponseFormat {
|
||||
XML,
|
||||
JSON,
|
||||
JSONP
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './SubsonicErrorCode';
|
||||
export * from './SubsonicResponseFormat';
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as crypto from "node:crypto";
|
||||
import {SubsonicResponseFormat, SubsonicErrorCode} from "../enum";
|
||||
import Koa from "koa";
|
||||
import {SubsonicError} from "./error";
|
||||
|
||||
export type SubsonicContextPrimitive = {
|
||||
format: SubsonicResponseFormat;
|
||||
apikey: string | null;
|
||||
version: string | null;
|
||||
client: string | null;
|
||||
callbackName: string | null;
|
||||
};
|
||||
|
||||
export const STATIC_SUBSONIC_PASSWORD = 'apikey';
|
||||
|
||||
export class SubsonicContext {
|
||||
constructor(
|
||||
public readonly format: SubsonicResponseFormat,
|
||||
public readonly apikey: string | null = null,
|
||||
public readonly version: string | null = null,
|
||||
public readonly client: string | null = null,
|
||||
public readonly callbackName: string | null = null,
|
||||
) {
|
||||
if (this.version === null) this.version = '1.1.0';
|
||||
if (!this.callbackName) this.callbackName = 'parseResponse';
|
||||
}
|
||||
|
||||
static fromQueryParams(req: Koa.Request) {
|
||||
const queryParams = SubsonicContext.emptyPrimitive();
|
||||
try {
|
||||
if (req.query['f'] === 'json') {
|
||||
queryParams.format = SubsonicResponseFormat.JSON;
|
||||
}
|
||||
|
||||
if (req.query['f'] === 'jsonp') {
|
||||
queryParams.format = SubsonicResponseFormat.JSONP;
|
||||
if (req.query['callback'] && !Array.isArray(req.query['callback'])) {
|
||||
queryParams.callbackName = req.query['callback'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.query['v'] || Array.isArray(req.query['v'])) {
|
||||
throw new Error('No api version provided');
|
||||
}
|
||||
queryParams.version = req.query['v'];
|
||||
|
||||
if (req.query['apikey']) {
|
||||
if (req.query['u'] || req.query['t'] || req.query['s'] || req.query['p']) {
|
||||
throw new SubsonicError(SubsonicErrorCode.ConflictingAuthSchemes, 'Cannot provide params "p", "t", "s", "u" when specifying "apikey"');
|
||||
}
|
||||
if (Array.isArray(req.query['apikey'])) {
|
||||
throw new SubsonicError(SubsonicErrorCode.ConflictingAuthSchemes, 'Multiple "apikey" params provided');
|
||||
}
|
||||
queryParams.apikey = req.query['apikey'];
|
||||
} else {
|
||||
if (req.query['p']) {
|
||||
throw new SubsonicError(SubsonicErrorCode.SpecifiedAuthNotSupported, 'Subsonic API password authentication not supported in JukeSquare');
|
||||
}
|
||||
|
||||
if (!req.query['t'] || Array.isArray(req.query['t'])) {
|
||||
throw new SubsonicError(SubsonicErrorCode.InvalidAuth, '(Legacy Auth) No token provided');
|
||||
}
|
||||
|
||||
if (!req.query['s'] || Array.isArray(req.query['s'])) {
|
||||
throw new SubsonicError(SubsonicErrorCode.InvalidAuth, '(Legacy Auth) No token salt provided');
|
||||
}
|
||||
|
||||
/* For backwards compatability with older Subsonic clients, we support "user and password" auth using
|
||||
* a valid apikey as the username and static string "apikey" as the password.
|
||||
* We force this specific password to allow dropping out very early if we suspect a real username/password pair have been provided.
|
||||
* Effectively this is a way of "opting-in" to legacy auth support.
|
||||
*/
|
||||
const expectedToken = SubsonicContext.hashToken(STATIC_SUBSONIC_PASSWORD, req.query['s']);
|
||||
if (req.query['t'] !== expectedToken) {
|
||||
throw new SubsonicError(SubsonicErrorCode.InvalidAuth, '(Legacy Auth) Token password was not provided as "apikey" - Subsonic Api support in JukeSquare requires apikey to be provided as username and "apikey" provided as password');
|
||||
}
|
||||
|
||||
if (!req.query['u'] || Array.isArray(req.query['u'])) {
|
||||
throw new SubsonicError(SubsonicErrorCode.InvalidAuth, '(Legacy Auth) No user provided - user\'s apikey must be provided in username field');
|
||||
}
|
||||
queryParams.apikey = req.query['u'];
|
||||
}
|
||||
|
||||
if (!req.query['c'] || Array.isArray(req.query['c'])) {
|
||||
throw new SubsonicError(SubsonicErrorCode.InvalidAuth, 'No client name provided');
|
||||
}
|
||||
queryParams.client = req.query['c'];
|
||||
|
||||
return SubsonicContext.fromPrimitive(queryParams);
|
||||
} catch (e) {
|
||||
/* Inject whatever context we have gathered into the error before bubbling up */
|
||||
if (e instanceof SubsonicError) {
|
||||
e.setContext(SubsonicContext.fromPrimitive(queryParams));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static empty(): SubsonicContext {
|
||||
return new SubsonicContext(
|
||||
SubsonicResponseFormat.XML,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
static fromPrimitive(primitive: SubsonicContextPrimitive): SubsonicContext {
|
||||
return new SubsonicContext(
|
||||
primitive.format,
|
||||
primitive.apikey,
|
||||
primitive.version,
|
||||
primitive.client,
|
||||
primitive.callbackName,
|
||||
);
|
||||
}
|
||||
|
||||
static emptyPrimitive(): SubsonicContextPrimitive {
|
||||
return {
|
||||
format: SubsonicResponseFormat.XML,
|
||||
apikey: null,
|
||||
version: null,
|
||||
client: null,
|
||||
callbackName: null,
|
||||
} satisfies SubsonicContextPrimitive;
|
||||
}
|
||||
|
||||
static hashToken(token: string, salt: string): string {
|
||||
return crypto.createHash('md5').update(`${token}${salt}`).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import {SubsonicErrorCode} from "../enum";
|
||||
import {SubsonicContext} from "./context";
|
||||
|
||||
export class SubsonicError extends Error {
|
||||
constructor(
|
||||
public code: SubsonicErrorCode,
|
||||
public message: string = '',
|
||||
public context: SubsonicContext | null = null,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public setContext(context: SubsonicContext): void {
|
||||
this.context = context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Koa from "koa";
|
||||
import {SubsonicContext} from "../context";
|
||||
|
||||
export async function extractSubsonicApiContext(ctx: Koa.Context, next: () => Promise<any>) {
|
||||
ctx.subsonicContext = SubsonicContext.fromQueryParams(ctx.request);
|
||||
await next();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './subsonicErrorHandler';
|
||||
export * from './extractSubsonicApiContext';
|
||||
@@ -0,0 +1,35 @@
|
||||
import {SubsonicError} from "../error";
|
||||
import Koa from "koa";
|
||||
import {SubsonicErrorCode} from "../../enum/SubsonicErrorCode";
|
||||
import {SubsonicContext} from "../context";
|
||||
import {Renderer} from "../renderer";
|
||||
|
||||
export async function subsonicErrorHandler(ctx: Koa.Context, next: () => Promise<any>) {
|
||||
try {
|
||||
await next();
|
||||
} catch (e) {
|
||||
if (e instanceof SubsonicError) {
|
||||
ctx.body = Renderer.renderError(e);
|
||||
return;
|
||||
}
|
||||
const context = ctx.store?.subsonicContext ?? SubsonicContext.empty();
|
||||
|
||||
if (e instanceof Error) {
|
||||
const genericError = new SubsonicError(
|
||||
SubsonicErrorCode.Generic,
|
||||
`Generic Error: ${e.message}`,
|
||||
context
|
||||
);
|
||||
|
||||
ctx.body = Renderer.renderError(genericError);
|
||||
return;
|
||||
}
|
||||
|
||||
const unknownError = new SubsonicError(
|
||||
SubsonicErrorCode.Generic,
|
||||
`Unknown Error - JSON representation of error object returned: ${JSON.stringify(e)}`,
|
||||
context
|
||||
);
|
||||
ctx.body = Renderer.renderError(unknownError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './xml-responder';
|
||||
export * from './json-responder';
|
||||
export * from './jsonp-responder';
|
||||
@@ -0,0 +1,20 @@
|
||||
import {SubsonicErrorCode} from "../../enum/SubsonicErrorCode";
|
||||
|
||||
export class JsonResponder {
|
||||
static renderError(code: SubsonicErrorCode, message: string): string {
|
||||
return JSON.stringify({
|
||||
"subsonic-response": {
|
||||
status: "failed",
|
||||
version: "1.16.1",
|
||||
error:{
|
||||
code,
|
||||
message
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static createResponse(element: string | null): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {SubsonicErrorCode} from "../../enum/SubsonicErrorCode";
|
||||
import {JsonResponder} from "./json-responder";
|
||||
|
||||
export class JsonPResponder {
|
||||
static renderError(code: SubsonicErrorCode, message: string, callback: string): string {
|
||||
const json = JsonResponder.renderError(code, message);
|
||||
return `${callback}(${json});`;
|
||||
}
|
||||
|
||||
static createResponse(element: string | null): string {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {create} from 'xmlbuilder2';
|
||||
import {SubsonicErrorCode} from "../../enum/SubsonicErrorCode";
|
||||
|
||||
export class XmlResponder {
|
||||
private static buildRoot(ok: boolean = true) {
|
||||
return create({version: '1.0', encoding: 'utf-8'})
|
||||
.ele('subsonic-response', {
|
||||
xmlns: 'http://subsonic.org/restapi',
|
||||
status: ok ? 'ok' : 'failed',
|
||||
version: "1.16.1",
|
||||
})
|
||||
}
|
||||
|
||||
static renderError(code: SubsonicErrorCode, message: string) {
|
||||
return XmlResponder.buildRoot( false)
|
||||
.ele('error', {
|
||||
code,
|
||||
message,
|
||||
})
|
||||
.end({prettyPrint: true});
|
||||
}
|
||||
|
||||
static createResponse(element: object | null): any {
|
||||
const root = XmlResponder.buildRoot();
|
||||
if (element) {}
|
||||
|
||||
return root.end({prettyPrint: true});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {SubsonicResponseFormat} from "../enum";
|
||||
import {SubsonicError} from "./error";
|
||||
import {SubsonicContext} from "./context";
|
||||
import {JsonPResponder, JsonResponder, XmlResponder} from "./renderBackends";
|
||||
|
||||
export class Renderer extends Error {
|
||||
static render(message: object | null, context: SubsonicContext | null = null) {
|
||||
switch (context?.format) {
|
||||
case SubsonicResponseFormat.XML:
|
||||
default:
|
||||
return XmlResponder.createResponse(message);
|
||||
case SubsonicResponseFormat.JSON:
|
||||
return '';
|
||||
case SubsonicResponseFormat.JSONP:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
static renderError(e: SubsonicError) {
|
||||
switch (e.context?.format) {
|
||||
case SubsonicResponseFormat.XML:
|
||||
default:
|
||||
return XmlResponder.renderError(e.code, e.message);
|
||||
case SubsonicResponseFormat.JSON:
|
||||
return JsonResponder.renderError(e.code, e.message);
|
||||
case SubsonicResponseFormat.JSONP:
|
||||
return JsonPResponder.renderError(e.code, e.message, e.context?.callbackName || 'parseResponse');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {BaseRouter, get, middleware, post, router} from "../base/router";
|
||||
import Koa from "koa";
|
||||
import {extractSubsonicApiContext, subsonicErrorHandler} from "./middleware";
|
||||
import {Renderer} from "./renderer";
|
||||
|
||||
@router('/rest')
|
||||
@middleware(subsonicErrorHandler)
|
||||
@middleware(extractSubsonicApiContext)
|
||||
export class SubsonicRouter extends BaseRouter {
|
||||
@get('/ping')
|
||||
ping(ctx: Koa.Context) {
|
||||
console.log('ping');
|
||||
ctx.body = Renderer.render(null, ctx.subsonicContext)
|
||||
}
|
||||
|
||||
@get('/ping2')
|
||||
@post('/ping2')
|
||||
ping2(ctx: Koa.Context) {
|
||||
console.log('ping');
|
||||
ctx.body = Renderer.render(null, ctx.subsonicContext)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user