This commit is contained in:
simojenki
2022-07-08 15:24:07 +10:00
parent 166a4b5ec2
commit 38f53168fa
9 changed files with 300 additions and 109 deletions

View File

@@ -1,8 +1,16 @@
import { AxiosPromise, AxiosRequestConfig, ResponseType } from "axios"; import {
AxiosPromise,
AxiosRequestConfig,
Method,
ResponseType,
} from "axios";
export interface Http { export interface Http {
(config: AxiosRequestConfig): AxiosPromise<any>; (config: AxiosRequestConfig): AxiosPromise<any>;
} }
export interface Http2 extends Http {
with: (defaults: Partial<RequestParams>) => Http2;
}
export type RequestParams = { export type RequestParams = {
baseURL: string; baseURL: string;
@@ -10,11 +18,25 @@ export type RequestParams = {
params: any; params: any;
headers: any; headers: any;
responseType: ResponseType; responseType: ResponseType;
method: Method;
}; };
export const http = const wrap = (http2: Http2, defaults: Partial<RequestParams>): Http2 => {
(base: Http, defaults: Partial<RequestParams>): Http => const f = ((config: AxiosRequestConfig) => http2(merge(defaults, config))) as Http2;
(config: AxiosRequestConfig) => { f.with = (defaults: Partial<RequestParams>) => wrap(f, defaults);
return f;
};
export const http2From = (http: Http): Http2 => {
const f = ((config: AxiosRequestConfig) => http(config)) as Http2;
f.with = (defaults: Partial<RequestParams>) => wrap(f, defaults);
return f;
}
const merge = (
defaults: Partial<RequestParams>,
config: AxiosRequestConfig
) => {
let toApply = { let toApply = {
...defaults, ...defaults,
...config, ...config,
@@ -37,5 +59,8 @@ export const http =
}, },
}; };
} }
return base(toApply); return toApply;
}; };
export const http =
(base: Http, defaults: Partial<RequestParams>): Http => (config: AxiosRequestConfig) => base(merge(defaults, config));

View File

@@ -33,13 +33,10 @@ import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
import { Icon, ICONS, festivals, features } from "./icon"; import { Icon, ICONS, festivals, features } from "./icon";
import _, { shuffle } from "underscore"; import _, { shuffle } from "underscore";
import morgan from "morgan"; import morgan from "morgan";
import { takeWithRepeats } from "./utils"; import { mask, takeWithRepeats } from "./utils";
import { parse } from "./burn"; import { parse } from "./burn";
import { axiosImageFetcher, ImageFetcher } from "./images"; import { axiosImageFetcher, ImageFetcher } from "./images";
import { import { JWTSmapiLoginTokens, SmapiAuthTokens } from "./smapi_auth";
JWTSmapiLoginTokens,
SmapiAuthTokens,
} from "./smapi_auth";
export const BONOB_ACCESS_TOKEN_HEADER = "bat"; export const BONOB_ACCESS_TOKEN_HEADER = "bat";
@@ -377,23 +374,28 @@ function server(
logger.info( logger.info(
`${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify( `${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify(
req.query req.query
)}, headers=${JSON.stringify({ ...req.headers, "bnbt": "*****", "bnbk": "*****" })}` )}, headers=${JSON.stringify(mask(req.headers, ["bnbt", "bnbk"]))}`
); );
const serviceToken = pipe( const serviceToken = pipe(
E.fromNullable("Missing bnbt header")(req.headers["bnbt"] as string), E.fromNullable("Missing bnbt header")(req.headers["bnbt"] as string),
E.chain(token => pipe( E.chain((token) =>
pipe(
E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string), E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string),
E.map(key => ({ token, key })) E.map((key) => ({ token, key }))
)), )
),
E.chain((auth) => E.chain((auth) =>
pipe( pipe(
smapiAuthTokens.verify(auth), smapiAuthTokens.verify(auth),
E.mapLeft((_) => "Auth token failed to verify") E.mapLeft((_) => "Auth token failed to verify")
) )
), ),
E.getOrElseW(() => undefined) E.getOrElseW((e: string) => {
) logger.error(`Failed to get serviceToken for stream: ${e}`);
return undefined;
})
);
if (!serviceToken) { if (!serviceToken) {
return res.status(401).send(); return res.status(401).send();

View File

@@ -5,7 +5,7 @@ import axios from "axios";
import randomstring from "randomstring"; import randomstring from "randomstring";
import _ from "underscore"; import _ from "underscore";
// todo: rename http2 to http // todo: rename http2 to http
import { Http, http as http2, RequestParams } from "../http"; import { Http2, http2From } from "../http";
import { import {
Credentials, Credentials,
@@ -17,7 +17,7 @@ import {
import { b64Encode, b64Decode } from "../b64"; import { b64Encode, b64Decode } from "../b64";
import { axiosImageFetcher, ImageFetcher } from "../images"; import { axiosImageFetcher, ImageFetcher } from "../images";
import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library"; import { navidromeMusicLibrary, SubsonicGenericMusicLibrary } from "./library";
import { getJSON as getJSON } from "./subsonic_http"; import { client } from "./subsonic_http";
export const t = (password: string, s: string) => export const t = (password: string, s: string) =>
Md5.hashStr(`${password}${s}`); Md5.hashStr(`${password}${s}`);
@@ -101,7 +101,7 @@ export class Subsonic implements MusicService {
// todo: why is this in here? // todo: why is this in here?
externalImageFetcher: ImageFetcher; externalImageFetcher: ImageFetcher;
subsonicHttp: Http; subsonic: Http2;
constructor( constructor(
url: string, url: string,
@@ -111,29 +111,22 @@ export class Subsonic implements MusicService {
this.url = url; this.url = url;
this.streamClientApplication = streamClientApplication; this.streamClientApplication = streamClientApplication;
this.externalImageFetcher = externalImageFetcher; this.externalImageFetcher = externalImageFetcher;
this.subsonicHttp = http2(axios, { this.subsonic = http2From(axios).with({
baseURL: this.url, baseURL: this.url,
params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION }, params: { v: "1.16.1", c: DEFAULT_CLIENT_APPLICATION },
headers: { "User-Agent": "bonob" }, headers: { "User-Agent": "bonob" },
}); });
} }
authenticatedSubsonicHttp = (credentials: Credentials) => asAuthParams = (credentials: Credentials) => ({
http2(this.subsonicHttp, {
params: {
u: credentials.username, u: credentials.username,
...t_and_s(credentials.password), ...t_and_s(credentials.password),
}, })
});
GET = (query: Partial<RequestParams>) => ({
asJSON: <T>() => getJSON<T>(http2(this.subsonicHttp, query)),
});
generateToken = (credentials: Credentials) => generateToken = (credentials: Credentials) =>
pipe( pipe(
TE.tryCatch( TE.tryCatch(
() => getJSON<PingResponse>(http2(this.authenticatedSubsonicHttp(credentials), { url: "/rest/ping.view" })), () => client(this.subsonic.with({ params: this.asAuthParams(credentials) } ))({ method: 'get', url: "/rest/ping.view" }).asJSON<PingResponse>(),
(e) => new AuthFailure(e as string) (e) => new AuthFailure(e as string)
), ),
TE.chain(({ type }) => TE.chain(({ type }) =>
@@ -168,7 +161,7 @@ export class Subsonic implements MusicService {
): Promise<SubsonicMusicLibrary> => { ): Promise<SubsonicMusicLibrary> => {
const subsonicGenericLibrary = new SubsonicGenericMusicLibrary( const subsonicGenericLibrary = new SubsonicGenericMusicLibrary(
this.streamClientApplication, this.streamClientApplication,
this.authenticatedSubsonicHttp(credentials) this.subsonic.with({ params: this.asAuthParams(credentials) } )
); );
if (credentials.type == "navidrome") { if (credentials.type == "navidrome") {
return Promise.resolve( return Promise.resolve(

View File

@@ -38,8 +38,8 @@ import {
import axios from "axios"; import axios from "axios";
import { asURLSearchParams } from "../utils"; import { asURLSearchParams } from "../utils";
import { artistSummaryFromNDArtist, NDArtist } from "./navidrome"; import { artistSummaryFromNDArtist, NDArtist } from "./navidrome";
import { Http, http as newHttp, RequestParams } from "../http"; import { Http2, RequestParams } from "../http";
import { getRaw2, getJSON } from "./subsonic_http"; import { client } from "./subsonic_http";
type album = { type album = {
id: string; id: string;
@@ -276,20 +276,17 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary { export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
streamClientApplication: StreamClientApplication; streamClientApplication: StreamClientApplication;
subsonicHttp: Http; subsonicHttp: Http2;
constructor( constructor(
streamClientApplication: StreamClientApplication, streamClientApplication: StreamClientApplication,
subsonicHttp: Http subsonicHttp: Http2
) { ) {
this.streamClientApplication = streamClientApplication; this.streamClientApplication = streamClientApplication;
this.subsonicHttp = subsonicHttp; this.subsonicHttp = subsonicHttp;
} }
GET = (query: Partial<RequestParams>) => ({ GET = (query: Partial<RequestParams>) => client(this.subsonicHttp)({ method: 'get', ...query });
asRAW: () => getRaw2(newHttp(this.subsonicHttp, query)),
asJSON: <T>() => getJSON<T>(newHttp(this.subsonicHttp, query)),
});
flavour = () => "subsonic"; flavour = () => "subsonic";
@@ -390,7 +387,6 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
trackId: string; trackId: string;
range: string | undefined; range: string | undefined;
}) => }) =>
// todo: all these headers and stuff can be rolled into httpeee
this.getTrack(trackId).then((track) => this.getTrack(trackId).then((track) =>
this.GET({ this.GET({
url: "/rest/stream", url: "/rest/stream",
@@ -398,20 +394,10 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
id: trackId, id: trackId,
c: this.streamClientApplication(track), c: this.streamClientApplication(track),
}, },
headers: pipe( headers: range != undefined ? { Range: range } : {},
range,
O.fromNullable,
O.map((range) => ({
// "User-Agent": USER_AGENT,
Range: range,
})),
O.getOrElse(() => ({
// "User-Agent": USER_AGENT,
}))
),
responseType: "stream", responseType: "stream",
}) })
.asRAW() .asRaw()
.then((res) => ({ .then((res) => ({
status: res.status, status: res.status,
headers: { headers: {
@@ -717,7 +703,7 @@ export class SubsonicGenericMusicLibrary implements SubsonicMusicLibrary {
url: "/rest/getCoverArt", url: "/rest/getCoverArt",
params: { id, size }, params: { id, size },
responseType: "arraybuffer", responseType: "arraybuffer",
}).asRAW(); }).asRaw();
private getTrack = (id: string) => private getTrack = (id: string) =>
this.GET({ this.GET({

View File

@@ -1,9 +1,7 @@
import { import { AxiosResponse } from "axios";
isError, import { isError, SubsonicEnvelope } from ".";
SubsonicEnvelope,
} from ".";
// todo: rename http2 to http // todo: rename http2 to http
import { Http, http as newHttp } from "../http"; import { Http2, RequestParams } from "../http";
export type HttpResponse = { export type HttpResponse = {
data: any; data: any;
@@ -11,21 +9,7 @@ export type HttpResponse = {
headers: any; headers: any;
}; };
export const getRaw2 = (http: Http) => const asJSON = <T>(response: HttpResponse): T => {
http({ method: "get" })
.catch((e) => {
throw `Subsonic failed with: ${e}`;
})
.then((response) => {
if (response.status != 200 && response.status != 206) {
throw `Subsonic failed with a ${response.status || "no!"} status`;
} else return response;
});
export const getJSON = async <T>(http: Http): Promise<T> =>
getRaw2(newHttp(http, { params: { f: "json" } })).then(asJSON) as Promise<T>;
export const asJSON = <T>(response: HttpResponse): T => {
const subsonicResponse = (response.data as SubsonicEnvelope)[ const subsonicResponse = (response.data as SubsonicEnvelope)[
"subsonic-response" "subsonic-response"
]; ];
@@ -33,6 +17,35 @@ export const asJSON = <T>(response: HttpResponse): T => {
throw `Subsonic error:${subsonicResponse.error.message}`; throw `Subsonic error:${subsonicResponse.error.message}`;
else return subsonicResponse as unknown as T; else return subsonicResponse as unknown as T;
}; };
const throwUp = (error: any) => {
throw `Subsonic failed with: ${error}`;
};
const verifyResponse = (response: AxiosResponse<any>) => {
if (response.status != 200 && response.status != 206) {
throw `Subsonic failed with a ${response.status || "no!"} status`;
} else return response;
};
export interface SubsonicHttpResponse {
asRaw(): Promise<AxiosResponse<any>>;
asJSON<T>(): Promise<T>;
}
export interface SubsonicHttp {
(query: Partial<RequestParams>): SubsonicHttpResponse;
}
export const client = (http: Http2): SubsonicHttp => {
return (query: Partial<RequestParams>): SubsonicHttpResponse => {
return {
asRaw: () => http(query).catch(throwUp).then(verifyResponse),
asJSON: <T>() =>
http
.with({ params: { f: "json" } })(query)
.catch(throwUp)
.then(verifyResponse)
.then(asJSON) as Promise<T>,
};
};
};

View File

@@ -22,11 +22,21 @@ export const asURLSearchParams = (q: any) => {
return urlSearchParams; return urlSearchParams;
}; };
export function takeWithRepeats<T>(things: T[], count: number) { export function takeWithRepeats<T>(things: T[], count: number) {
const result = []; const result = [];
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
result.push(things[i % things.length]) result.push(things[i % things.length]);
} }
return result; return result;
} }
export const mask = (thing: any, fields: string[]) =>
fields.reduce(
(res: any, key: string) => {
if (Object.keys(res).includes(key)) {
res[key] = "****";
}
return res;
},
{ ...thing }
);

View File

@@ -1,4 +1,4 @@
import { http, } from "../src/http"; import { http, http2From, } from "../src/http";
describe("http", () => { describe("http", () => {
const mockAxios = jest.fn(); const mockAxios = jest.fn();
@@ -11,6 +11,7 @@ describe("http", () => {
describe.each([ describe.each([
["baseURL"], ["baseURL"],
["url"], ["url"],
["method"],
])('%s', (field) => { ])('%s', (field) => {
const getValue = (value: string) => { const getValue = (value: string) => {
const thing = {} as any; const thing = {} as any;
@@ -136,3 +137,141 @@ describe("http", () => {
}); });
}) })
}); });
describe("http2", () => {
const mockAxios = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe.each([
["baseURL"],
["url"],
["method"],
])('%s', (field) => {
const fieldWithValue = (value: string) => {
const thing = {} as any;
thing[field] = value;
return thing;
};
const base = http2From(mockAxios).with(fieldWithValue('default'));
describe("using default", () => {
it("should use the default", () => {
base({})
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('default'));
});
});
describe("overriding", () => {
it("should use the override", () => {
base(fieldWithValue('override'))
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('override'));
});
});
describe("wrapping", () => {
const firstLayer = http2From(base).with(fieldWithValue('level1'));
const secondLayer = firstLayer.with(fieldWithValue('level2'));
describe("when the outter call provides a value", () => {
it("should apply it", () => {
secondLayer(fieldWithValue('outter'))
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('outter'));
});
});
describe("when the outter call does not provide a value", () => {
it("should use the second layer", () => {
secondLayer({ })
expect(mockAxios).toHaveBeenCalledWith(fieldWithValue('level2'));
});
});
});
});
describe("requestType", () => {
const base = http2From(mockAxios).with({ responseType: 'stream' });
describe("using default", () => {
it("should use the default", () => {
base({})
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'stream' });
});
});
describe("overriding", () => {
it("should use the override", () => {
base({ responseType: 'arraybuffer' })
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'arraybuffer' });
});
});
describe("wrapping", () => {
const firstLayer = base.with({ responseType: 'arraybuffer' });
const secondLayer = firstLayer.with({ responseType: 'blob' });
describe("when the outter call provides a value", () => {
it("should apply it", () => {
secondLayer({ responseType: 'text' })
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'text' });
});
});
describe("when the outter call does not provide a value", () => {
it("should use the second layer", () => {
secondLayer({ })
expect(mockAxios).toHaveBeenCalledWith({ responseType: 'blob' });
});
});
});
});
describe.each([
["params"],
["headers"],
])('%s', (field) => {
const fieldWithValues = (values: any) => {
const thing = {} as any;
thing[field] = values;
return thing;
}
const base = http2From(mockAxios).with(fieldWithValues({ a: 1, b: 2, c: 3, d: 4 }));
describe("using default", () => {
it("should use the default", () => {
base({});
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 2, c: 3, d: 4 }));
});
});
describe("overriding", () => {
it("should use the override", () => {
base(fieldWithValues({ b: 22, e: 5 }));
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 22, c: 3, d: 4, e: 5 }));
});
});
describe("wrapping", () => {
const firstLayer = base.with(fieldWithValues({ b: 22 }));
const secondLayer = firstLayer.with(fieldWithValues({ c: 33 }));
describe("when the outter call provides a value", () => {
it("should apply it", () => {
secondLayer(fieldWithValues({ a: 11, e: 5 }));
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 11, b: 22, c: 33, d: 4, e: 5 }));
});
});
describe("when the outter call does not provide a value", () => {
it("should use the second layer", () => {
secondLayer({ });
expect(mockAxios).toHaveBeenCalledWith(fieldWithValues({ a: 1, b: 22, c: 33, d: 4 }));
});
});
});
})
});

View File

@@ -46,7 +46,7 @@ import {
import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test"; import { EMPTY, error, FAILURE, subsonicOK, ok } from "../subsonic.test";
import { DODGY_IMAGE_NAME, t } from "../../src/subsonic"; import { DODGY_IMAGE_NAME, t } from "../../src/subsonic";
import { b64Encode } from "../../src/b64"; import { b64Encode } from "../../src/b64";
import { http as http2 } from "../../src/http"; import { http2From } from "../../src/http";
const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) =>
pipe( pipe(
@@ -511,7 +511,7 @@ describe("SubsonicGenericMusicLibrary", () => {
const generic = new SubsonicGenericMusicLibrary( const generic = new SubsonicGenericMusicLibrary(
streamClientApplication, streamClientApplication,
// todo: all this stuff doesnt need to be defaulted in here. // todo: all this stuff doesnt need to be defaulted in here.
http2(mockAxios, { http2From(mockAxios).with({
baseURL, baseURL,
params: authParams, params: authParams,
headers headers

View File

@@ -1,4 +1,4 @@
import { asURLSearchParams, takeWithRepeats } from "../src/utils"; import { asURLSearchParams, mask, takeWithRepeats } from "../src/utils";
describe("asURLSearchParams", () => { describe("asURLSearchParams", () => {
describe("empty q", () => { describe("empty q", () => {
@@ -46,8 +46,6 @@ describe("asURLSearchParams", () => {
}); });
}); });
describe("takeWithRepeat", () => { describe("takeWithRepeat", () => {
describe("when there is nothing in the input", () => { describe("when there is nothing in the input", () => {
it("should return an array of undefineds", () => { it("should return an array of undefineds", () => {
@@ -77,7 +75,32 @@ describe("takeWithRepeat", () => {
describe("when there more than the amount required", () => { describe("when there more than the amount required", () => {
it("should return the first n items", () => { it("should return the first n items", () => {
expect(takeWithRepeats(["a", "b", "c"], 2)).toEqual(["a", "b"]); expect(takeWithRepeats(["a", "b", "c"], 2)).toEqual(["a", "b"]);
expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual(["a", undefined]); expect(takeWithRepeats(["a", undefined, "c"], 2)).toEqual([
"a",
undefined,
]);
}); });
}); });
}); });
describe("mask", () => {
it.each([
[{}, ["a", "b"], {}],
[{ foo: "bar" }, ["a", "b"], { foo: "bar" }],
[{ a: 1 }, ["a", "b"], { a: "****" }],
[{ a: 1, b: "dog" }, ["a", "b"], { a: "****", b: "****" }],
[
{ a: 1, b: "dog", foo: "bar" },
["a", "b"],
{ a: "****", b: "****", foo: "bar" },
],
])(
"masking of %s, keys = %s, should result in %s",
(original: any, keys: string[], expected: any) => {
const copyOfOrig = JSON.parse(JSON.stringify(original));
const masked = mask(original, keys);
expect(masked).toEqual(expected);
expect(original).toEqual(copyOfOrig);
}
);
});