Initial commit with decorator router, and some basic subsonic api code

This commit is contained in:
2026-04-08 19:40:39 +01:00
commit 8e5a8d073f
19 changed files with 1178 additions and 0 deletions
+80
View File
@@ -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[]);
}
}
}
}
}
+13
View File
@@ -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,
}
+5
View File
@@ -0,0 +1,5 @@
export enum SubsonicResponseFormat {
XML,
JSON,
JSONP
}
+2
View File
@@ -0,0 +1,2 @@
export * from './SubsonicErrorCode';
export * from './SubsonicResponseFormat';
+133
View File
@@ -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');
}
}
+16
View File
@@ -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();
}
+2
View File
@@ -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);
}
}
+3
View File
@@ -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});
}
}
+30
View File
@@ -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');
}
}
}
+22
View File
@@ -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)
}
}