mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
basic navidrome implementation
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
"underscore":"^1.12.0",
|
||||
"uuid": "^8.3.2",
|
||||
"winston": "^3.3.3",
|
||||
"x2js": "^3.4.0",
|
||||
"xmldom-ts": "^0.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
16
src/app.ts
16
src/app.ts
@@ -1,19 +1,25 @@
|
||||
import sonos, { bonobService } from "./sonos";
|
||||
import server from "./server";
|
||||
import logger from "./logger";
|
||||
import { Navidrome } from './music_service';
|
||||
|
||||
import { Navidrome } from "./navidrome";
|
||||
import encryption from "./encryption";
|
||||
|
||||
const PORT = +(process.env["BONOB_PORT"] || 4534);
|
||||
const WEB_ADDRESS = process.env["BONOB_WEB_ADDRESS"] || `http://localhost:${PORT}`;
|
||||
const WEB_ADDRESS =
|
||||
process.env["BONOB_WEB_ADDRESS"] || `http://localhost:${PORT}`;
|
||||
|
||||
const bonob = bonobService(
|
||||
process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob",
|
||||
Number(process.env["BONOS_SONOS_SERVICE_ID"] || "246"),
|
||||
WEB_ADDRESS,
|
||||
'AppLink'
|
||||
"AppLink"
|
||||
);
|
||||
const app = server(
|
||||
sonos(process.env["BONOB_SONOS_SEED_HOST"]),
|
||||
bonob,
|
||||
WEB_ADDRESS,
|
||||
new Navidrome(process.env["BONOB_NAVIDROME_URL"] || "http://localhost:4533", encryption())
|
||||
);
|
||||
const app = server(sonos(process.env["BONOB_SONOS_SEED_HOST"]), bonob, WEB_ADDRESS, new Navidrome());
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Listening on ${PORT} available @ ${WEB_ADDRESS}`);
|
||||
|
||||
33
src/encryption.ts
Normal file
33
src/encryption.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-cbc"
|
||||
const IV = randomBytes(16);
|
||||
const KEY = randomBytes(32);
|
||||
|
||||
export type Hash = {
|
||||
iv: string,
|
||||
encryptedData: string
|
||||
}
|
||||
|
||||
export type Encryption = {
|
||||
encrypt: (value:string) => Hash
|
||||
decrypt: (hash: Hash) => string
|
||||
}
|
||||
|
||||
const encryption = (): Encryption => {
|
||||
return {
|
||||
encrypt: (value: string) => {
|
||||
const cipher = createCipheriv(ALGORITHM, KEY, IV);
|
||||
return {
|
||||
iv: IV.toString("hex"),
|
||||
encryptedData: Buffer.concat([cipher.update(value), cipher.final()]).toString("hex")
|
||||
};
|
||||
},
|
||||
decrypt: (hash: Hash) => {
|
||||
const decipher = createDecipheriv(ALGORITHM, KEY, Buffer.from(hash.iv, 'hex'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(hash.encryptedData, 'hex')), decipher.final()]).toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default encryption;
|
||||
@@ -1,10 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { Md5 } from "ts-md5/dist/md5";
|
||||
|
||||
const s = "foobar100";
|
||||
const navidrome = process.env["BONOB_NAVIDROME_URL"];
|
||||
const u = process.env["BONOB_USER"];
|
||||
const t = Md5.hashStr(`${process.env["BONOB_PASSWORD"]}${s}`);
|
||||
|
||||
export type Credentials = { username: string; password: string };
|
||||
|
||||
@@ -31,8 +24,8 @@ export type AuthFailure = {
|
||||
};
|
||||
|
||||
export interface MusicService {
|
||||
generateToken(credentials: Credentials): AuthSuccess | AuthFailure;
|
||||
login(authToken: string): MusicLibrary | AuthFailure;
|
||||
generateToken(credentials: Credentials): Promise<AuthSuccess | AuthFailure>;
|
||||
login(authToken: string): Promise<MusicLibrary>;
|
||||
}
|
||||
|
||||
export type Artist = {
|
||||
@@ -50,38 +43,3 @@ export interface MusicLibrary {
|
||||
artist(id: string): Artist;
|
||||
albums({ artistId, _index, _count }: { artistId?: string, _index?: number, _count?: number }): Album[];
|
||||
}
|
||||
|
||||
export class Navidrome implements MusicService {
|
||||
generateToken({ username }: Credentials) {
|
||||
return {
|
||||
authToken: `v1:${username}`,
|
||||
userId: username,
|
||||
nickname: username,
|
||||
};
|
||||
}
|
||||
|
||||
login(_: string) {
|
||||
return {
|
||||
artists: () => [],
|
||||
artist: (id: string) => ({
|
||||
id,
|
||||
name: id,
|
||||
}),
|
||||
albums: ({ artistId }: { artistId?: string }) => {
|
||||
console.log(artistId)
|
||||
return []
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ping = (): Promise<boolean> =>
|
||||
axios
|
||||
.get(
|
||||
`${navidrome}/rest/ping.view?u=${u}&t=${t}&s=${s}&v=1.16.1.0&c=myapp`
|
||||
)
|
||||
.then((_) => true)
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
91
src/navidrome.ts
Normal file
91
src/navidrome.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Md5 } from "ts-md5/dist/md5";
|
||||
import { Credentials, MusicService } from "./music_service";
|
||||
import X2JS from "x2js";
|
||||
|
||||
import axios from "axios";
|
||||
import { Encryption } from "./encryption";
|
||||
import randomString from "./random_string";
|
||||
|
||||
export const t = (password: string, s: string) =>
|
||||
Md5.hashStr(`${password}${s}`);
|
||||
|
||||
export type SubconicEnvelope = {
|
||||
"subsonic-response": SubsonicResponse;
|
||||
};
|
||||
|
||||
export type SubsonicResponse = {
|
||||
_status: string;
|
||||
};
|
||||
|
||||
export type SubsonicError = SubsonicResponse & {
|
||||
error: {
|
||||
_code: string;
|
||||
_message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function isError(
|
||||
subsonicResponse: SubsonicResponse
|
||||
): subsonicResponse is SubsonicError {
|
||||
return (subsonicResponse as SubsonicError).error !== undefined;
|
||||
}
|
||||
|
||||
export class Navidrome implements MusicService {
|
||||
url: string;
|
||||
encryption: Encryption;
|
||||
|
||||
constructor(url: string, encryption: Encryption) {
|
||||
this.url = url;
|
||||
this.encryption = encryption;
|
||||
}
|
||||
|
||||
generateToken = async ({ username, password }: Credentials) => {
|
||||
const s = randomString();
|
||||
return axios
|
||||
.get(
|
||||
`${this.url}/rest/ping.view?u=${username}&t=${t(
|
||||
password,
|
||||
s
|
||||
)}&s=${s}&v=1.16.1.0&c=bonob`
|
||||
)
|
||||
.then((response) => new X2JS().xml2js(response.data) as SubconicEnvelope)
|
||||
.then((json) => json["subsonic-response"])
|
||||
.then((json) => {
|
||||
if (isError(json)) throw json.error._message;
|
||||
else return json;
|
||||
})
|
||||
.then((_) => {
|
||||
return {
|
||||
authToken: Buffer.from(
|
||||
JSON.stringify(
|
||||
this.encryption.encrypt(JSON.stringify({ username, password }))
|
||||
)
|
||||
).toString("base64"),
|
||||
userId: username,
|
||||
nickname: username,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
parseToken = (token: string): Credentials =>
|
||||
JSON.parse(
|
||||
this.encryption.decrypt(
|
||||
JSON.parse(Buffer.from(token, "base64").toString("ascii"))
|
||||
)
|
||||
);
|
||||
|
||||
async login(_: string) {
|
||||
// const credentials: Credentials = this.parseToken(token);
|
||||
return Promise.resolve({
|
||||
artists: () => [],
|
||||
artist: (id: string) => ({
|
||||
id,
|
||||
name: id,
|
||||
}),
|
||||
albums: ({ artistId }: { artistId?: string }) => {
|
||||
console.log(artistId);
|
||||
return [];
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
6
src/random_string.ts
Normal file
6
src/random_string.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
const randomString = () => randomBytes(32).toString('hex')
|
||||
|
||||
export default randomString
|
||||
|
||||
@@ -69,28 +69,28 @@ function server(
|
||||
});
|
||||
});
|
||||
|
||||
app.post(LOGIN_ROUTE, (req, res) => {
|
||||
app.post(LOGIN_ROUTE, async (req, res) => {
|
||||
const { username, password, linkCode } = req.body;
|
||||
const authResult = musicService.generateToken({
|
||||
if (!linkCodes.has(linkCode)) {
|
||||
res.status(400).render("failure", {
|
||||
message: `Invalid linkCode!`,
|
||||
});
|
||||
} else {
|
||||
const authResult = await musicService.generateToken({
|
||||
username,
|
||||
password,
|
||||
});
|
||||
if (isSuccess(authResult)) {
|
||||
if (linkCodes.has(linkCode)) {
|
||||
linkCodes.associate(linkCode, authResult);
|
||||
res.render("success", {
|
||||
message: `Login successful`,
|
||||
});
|
||||
} else {
|
||||
res.status(400).render("failure", {
|
||||
message: `Invalid linkCode!`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.status(403).render("failure", {
|
||||
message: `Login failed, ${authResult.message}!`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get(STRINGS_ROUTE, (_, res) => {
|
||||
|
||||
11
src/smapi.ts
11
src/smapi.ts
@@ -6,7 +6,7 @@ import path from "path";
|
||||
import logger from "./logger";
|
||||
|
||||
import { LinkCodes } from "./link_codes";
|
||||
import { isFailure, MusicLibrary, MusicService } from "./music_service";
|
||||
import { MusicLibrary, MusicService } from "./music_service";
|
||||
|
||||
export const LOGIN_ROUTE = "/login";
|
||||
export const SOAP_PATH = "/ws/sonos";
|
||||
@@ -176,7 +176,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
getAppLink: () => sonosSoap.getAppLink(),
|
||||
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
|
||||
sonosSoap.getDeviceAuthToken({ linkCode }),
|
||||
getMetadata: (
|
||||
getMetadata: async (
|
||||
{
|
||||
id,
|
||||
index,
|
||||
@@ -194,17 +194,16 @@ function bindSmapiSoapServiceToExpress(
|
||||
},
|
||||
};
|
||||
}
|
||||
const login = musicService.login(
|
||||
const login = await musicService.login(
|
||||
headers.credentials.loginToken.token
|
||||
);
|
||||
if (isFailure(login)) {
|
||||
).catch(_ => {
|
||||
throw {
|
||||
Fault: {
|
||||
faultcode: "Client.LoginUnauthorized",
|
||||
faultstring: "Credentials not found...",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const musicLibrary = login as MusicLibrary;
|
||||
|
||||
|
||||
@@ -129,7 +129,6 @@ export function autoDiscoverySonos(sonosSeedHost?: string): Sonos {
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("booom");
|
||||
logger.error(`Failed looking for sonos devices ${e}`);
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SonosDevice } from "@svrooij/sonos/lib";
|
||||
import { ArtistWithAlbums } from "in_memory_music_service";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { Credentials } from "../src/smapi";
|
||||
|
||||
import { Service, Device } from "../src/sonos";
|
||||
import { Album, Artist } from "../src/music_service";
|
||||
|
||||
const randomInt = (max: number) => Math.floor(Math.random() * max);
|
||||
const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`;
|
||||
@@ -68,6 +68,10 @@ export function someCredentials(token: string): Credentials {
|
||||
}
|
||||
}
|
||||
|
||||
export type ArtistWithAlbums = Artist & {
|
||||
albums: Album[];
|
||||
};
|
||||
|
||||
export const BOB_MARLEY: ArtistWithAlbums = {
|
||||
id: uuid(),
|
||||
name: "Bob Marley",
|
||||
|
||||
12
tests/encryption.test.ts
Normal file
12
tests/encryption.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import encryption from '../src/encryption';
|
||||
|
||||
describe("encrypt", () => {
|
||||
const e = encryption();
|
||||
|
||||
it("can encrypt and decrypt", () => {
|
||||
const value = "bobs your uncle"
|
||||
const hash = e.encrypt(value)
|
||||
expect(hash.encryptedData).not.toEqual(value);
|
||||
expect(e.decrypt(hash)).toEqual(value);
|
||||
});
|
||||
})
|
||||
@@ -1,20 +1,18 @@
|
||||
import {
|
||||
InMemoryMusicService,
|
||||
} from "./in_memory_music_service";
|
||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import { AuthSuccess, MusicLibrary } from "../src/music_service";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { BOB_MARLEY, MADONNA, BLONDIE } from './builders'
|
||||
import { BOB_MARLEY, MADONNA, BLONDIE } from "./builders";
|
||||
|
||||
describe("InMemoryMusicService", () => {
|
||||
const service = new InMemoryMusicService();
|
||||
|
||||
describe("generateToken", () => {
|
||||
it("should be able to generate a token and then use it to log in", () => {
|
||||
it("should be able to generate a token and then use it to log in", async () => {
|
||||
const credentials = { username: "bob", password: "smith" };
|
||||
|
||||
service.hasUser(credentials);
|
||||
|
||||
const token = service.generateToken(credentials) as AuthSuccess;
|
||||
const token = (await service.generateToken(credentials)) as AuthSuccess;
|
||||
|
||||
expect(token.userId).toEqual(credentials.username);
|
||||
expect(token.nickname).toEqual(credentials.username);
|
||||
@@ -24,35 +22,31 @@ describe("InMemoryMusicService", () => {
|
||||
expect(musicLibrary).toBeDefined();
|
||||
});
|
||||
|
||||
it("should fail with an exception if an invalid token is used", () => {
|
||||
it.only("should fail with an exception if an invalid token is used", async () => {
|
||||
const credentials = { username: "bob", password: "smith" };
|
||||
|
||||
service.hasUser(credentials);
|
||||
|
||||
const token = service.generateToken(credentials) as AuthSuccess;
|
||||
const token = (await service.generateToken(credentials)) as AuthSuccess;
|
||||
|
||||
service.clear();
|
||||
|
||||
expect(service.login(token.authToken)).toEqual({
|
||||
message: "Invalid auth token",
|
||||
});
|
||||
return expect(service.login(token.authToken)).rejects.toEqual("Invalid auth token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Music Library", () => {
|
||||
|
||||
|
||||
const user = { username: "user100", password: "password100" };
|
||||
let musicLibrary: MusicLibrary;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
service.clear();
|
||||
|
||||
service.hasArtists(BOB_MARLEY, MADONNA, BLONDIE);
|
||||
service.hasUser(user);
|
||||
|
||||
const token = service.generateToken(user) as AuthSuccess;
|
||||
musicLibrary = service.login(token.authToken) as MusicLibrary;
|
||||
const token = (await service.generateToken(user)) as AuthSuccess;
|
||||
musicLibrary = (await service.login(token.authToken)) as MusicLibrary;
|
||||
});
|
||||
|
||||
describe("artists", () => {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { option as O } from "fp-ts";
|
||||
import { pipe } from "fp-ts/lib/function";
|
||||
|
||||
import { ArtistWithAlbums } from "./builders";
|
||||
import {
|
||||
MusicService,
|
||||
Credentials,
|
||||
AuthSuccess,
|
||||
AuthFailure,
|
||||
Artist,
|
||||
Album,
|
||||
MusicLibrary,
|
||||
} from "../src/music_service";
|
||||
|
||||
export type ArtistWithAlbums = Artist & {
|
||||
albums: Album[];
|
||||
};
|
||||
|
||||
const artistWithAlbumsToArtist = (it: ArtistWithAlbums): Artist => ({
|
||||
id: it.id,
|
||||
@@ -36,30 +34,26 @@ export class InMemoryMusicService implements MusicService {
|
||||
generateToken({
|
||||
username,
|
||||
password,
|
||||
}: Credentials): AuthSuccess | AuthFailure {
|
||||
}: Credentials): Promise<AuthSuccess | AuthFailure> {
|
||||
if (
|
||||
username != undefined &&
|
||||
password != undefined &&
|
||||
this.users[username] == password
|
||||
) {
|
||||
return {
|
||||
return Promise.resolve({
|
||||
authToken: JSON.stringify({ username, password }),
|
||||
userId: username,
|
||||
nickname: username,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
return { message: `Invalid user:${username}` };
|
||||
return Promise.resolve({ message: `Invalid user:${username}` });
|
||||
}
|
||||
}
|
||||
|
||||
login(token: string): MusicLibrary | AuthFailure {
|
||||
login(token: string): Promise<MusicLibrary> {
|
||||
const credentials = JSON.parse(token) as Credentials;
|
||||
if (this.users[credentials.username] != credentials.password) {
|
||||
return {
|
||||
message: "Invalid auth token",
|
||||
};
|
||||
}
|
||||
return {
|
||||
if (this.users[credentials.username] != credentials.password) return Promise.reject("Invalid auth token")
|
||||
return Promise.resolve({
|
||||
artists: () => this.artists.map(artistWithAlbumsToArtist),
|
||||
artist: (id: string) =>
|
||||
pipe(
|
||||
@@ -90,7 +84,7 @@ export class InMemoryMusicService implements MusicService {
|
||||
.flatMap((it) => it.albums)
|
||||
.slice(i0, i1);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
hasUser(credentials: Credentials) {
|
||||
|
||||
75
tests/navidrome.test.ts
Normal file
75
tests/navidrome.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Md5 } from "ts-md5/dist/md5";
|
||||
|
||||
import { Navidrome, t } from "../src/navidrome";
|
||||
import encryption from "../src/encryption";
|
||||
|
||||
import axios from "axios";
|
||||
jest.mock("axios");
|
||||
|
||||
import randomString from "../src/random_string";
|
||||
jest.mock("../src/random_string");
|
||||
|
||||
describe("t", () => {
|
||||
it("should be an md5 of the password and the salt", () => {
|
||||
const p = "password123";
|
||||
const s = "saltydog";
|
||||
expect(t(p, s)).toEqual(Md5.hashStr(`${p}${s}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("navidrome", () => {
|
||||
const url = "http://127.0.0.22:4567";
|
||||
const username = "user1";
|
||||
const password = "pass1";
|
||||
const salt = "saltysalty";
|
||||
|
||||
const navidrome = new Navidrome(url, encryption());
|
||||
|
||||
const mockedRandomString = (randomString as unknown) as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
axios.get = jest.fn();
|
||||
|
||||
mockedRandomString.mockReturnValue(salt);
|
||||
});
|
||||
|
||||
describe("generateToken", () => {
|
||||
describe("when the credentials are valid", () => {
|
||||
it("should be able to generate a token and then login using it", async () => {
|
||||
(axios.get as jest.Mock).mockResolvedValue({
|
||||
status: 200,
|
||||
data: `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
|
||||
</subsonic-response>`,
|
||||
});
|
||||
|
||||
const token = await navidrome.generateToken({ username, password });
|
||||
|
||||
expect(token.authToken).toBeDefined();
|
||||
expect(token.nickname).toEqual(username);
|
||||
expect(token.userId).toEqual(username);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
`${url}/rest/ping.view?u=${username}&t=${t(
|
||||
password,
|
||||
salt
|
||||
)}&s=${salt}&v=1.16.1.0&c=bonob`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the credentials are not valid", () => {
|
||||
it("should be able to generate a token and then login using it", async () => {
|
||||
(axios.get as jest.Mock).mockResolvedValue({
|
||||
status: 200,
|
||||
data: `<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
|
||||
<error code="40" message="Wrong username or password"></error>
|
||||
</subsonic-response>`,
|
||||
});
|
||||
|
||||
return expect(navidrome.generateToken({ username, password })).rejects.toMatch("Wrong username or password");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
16
tests/random_string.test.ts
Normal file
16
tests/random_string.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import randomString from "../src/random_string";
|
||||
|
||||
describe('randomString', () => {
|
||||
it('should produce different strings...', () => {
|
||||
const s1 = randomString()
|
||||
const s2 = randomString()
|
||||
const s3 = randomString()
|
||||
const s4 = randomString()
|
||||
|
||||
expect(s1.length).toEqual(64)
|
||||
|
||||
expect(s1).not.toEqual(s2);
|
||||
expect(s1).not.toEqual(s3);
|
||||
expect(s1).not.toEqual(s4);
|
||||
});
|
||||
});
|
||||
@@ -289,7 +289,7 @@ describe("api", () => {
|
||||
const username = "userThatGetsDeleted";
|
||||
const password = "password1";
|
||||
musicService.hasUser({ username, password });
|
||||
const token = musicService.generateToken({
|
||||
const token = await musicService.generateToken({
|
||||
username,
|
||||
password,
|
||||
}) as AuthSuccess;
|
||||
@@ -321,7 +321,7 @@ describe("api", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
musicService.hasUser({ username, password });
|
||||
token = musicService.generateToken({
|
||||
token = await musicService.generateToken({
|
||||
username,
|
||||
password,
|
||||
}) as AuthSuccess;
|
||||
|
||||
12
yarn.lock
12
yarn.lock
@@ -4988,6 +4988,13 @@ ws@^7.2.3:
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.3.tgz#1f9643de34a543b8edb124bdcbc457ae55a6e5cd"
|
||||
integrity sha512-hr6vCR76GsossIRsr8OLR9acVVm1jyfEWvhbNjtgPOrfvAlKzvyeg/P6r8RuDjRyrcQoPQT7K0DGEPc7Ae6jzA==
|
||||
|
||||
x2js@^3.4.0:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.yarnpkg.com/x2js/-/x2js-3.4.0.tgz#1414ad99062705086a4838e8dde4ecd06e8bd3a9"
|
||||
integrity sha512-1tozn7D51ghz2DAiy5U6R55qn9x2F3lHUxusOD0QtYlLSDGxyXjHfn0c508eXG1D7s8qqj54SiU5HsPEfhDIpg==
|
||||
dependencies:
|
||||
xmldom "^0.1.19"
|
||||
|
||||
xdg-basedir@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
|
||||
@@ -5021,6 +5028,11 @@ xmldom@0.1.27:
|
||||
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.27.tgz#d501f97b3bdb403af8ef9ecc20573187aadac0e9"
|
||||
integrity sha1-1QH5ezvbQDr4757MIFcxh6rawOk=
|
||||
|
||||
xmldom@^0.1.19:
|
||||
version "0.1.31"
|
||||
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
|
||||
integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
|
||||
|
||||
xpath-ts@^1.3.13:
|
||||
version "1.3.13"
|
||||
resolved "https://registry.yarnpkg.com/xpath-ts/-/xpath-ts-1.3.13.tgz#abca4f15dd7010161acf5b9cd01566f7b8d9541f"
|
||||
|
||||
Reference in New Issue
Block a user