moving smapi soap related classes into smapi.ts

This commit is contained in:
simojenki
2021-02-26 12:03:51 +11:00
parent 6c5b78cd6e
commit 12fe09dd7f
6 changed files with 215 additions and 213 deletions

View File

@@ -1,26 +1,17 @@
import express, { Express } from "express"; import express, { Express } from "express";
import * as Eta from "eta"; import * as Eta from "eta";
import { listen } from "soap";
import { readFileSync } from "fs";
import path from "path";
import morgan from "morgan"; import morgan from "morgan";
import crypto from "crypto";
import { import {
Sonos, Sonos,
Service, Service,
SOAP_PATH,
STRINGS_PATH,
PRESENTATION_MAP_PATH,
} from "./sonos"; } from "./sonos";
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE, LOGIN_ROUTE } from './smapi';
import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService, isSuccess } from "./music_service"; import { MusicService, isSuccess } from "./music_service";
import logger from "./logger"; // import logger from "./logger";
import bindSmapiSoapServiceToExpress from "./smapi";
const WSDL_FILE = path.resolve(
__dirname,
"Sonoswsdl-1.19.4-20190411.142401-3.wsdl"
);
function server( function server(
sonos: Sonos, sonos: Sonos,
@@ -70,14 +61,15 @@ function server(
}); });
}); });
app.get("/login", (req, res) => { app.get(LOGIN_ROUTE, (req, res) => {
res.render("login", { res.render("login", {
bonobService, bonobService,
linkCode: req.query.linkCode, linkCode: req.query.linkCode,
loginRoute: LOGIN_ROUTE
}); });
}); });
app.post("/login", (req, res) => { app.post(LOGIN_ROUTE, (req, res) => {
const { username, password, linkCode } = req.body; const { username, password, linkCode } = req.body;
const authResult = musicService.login({ const authResult = musicService.login({
username, username,
@@ -101,7 +93,7 @@ function server(
} }
}); });
app.get(STRINGS_PATH, (_, res) => { app.get(STRINGS_ROUTE, (_, res) => {
res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?> res.type("application/xml").send(`<?xml version="1.0" encoding="utf-8" ?>
<stringtables xmlns="http://sonos.com/sonosapi"> <stringtables xmlns="http://sonos.com/sonosapi">
<stringtable rev="1" xml:lang="en-US"> <stringtable rev="1" xml:lang="en-US">
@@ -111,94 +103,11 @@ function server(
`); `);
}); });
app.get(PRESENTATION_MAP_PATH, (_, res) => { app.get(PRESENTATION_MAP_ROUTE, (_, res) => {
res.send(""); res.send("");
}); });
const sonosService = { bindSmapiSoapServiceToExpress(app, SOAP_PATH, webAddress, linkCodes);
Sonos: {
SonosSoap: {
getAppLink: () => {
const linkCode = linkCodes.mint();
return {
getAppLinkResult: {
authorizeAccount: {
appUrlStringId: "AppLinkMessage",
deviceLink: {
regUrl: `${webAddress}/login?linkCode=${linkCode}`,
linkCode: linkCode,
showLinkCode: false,
},
},
},
};
},
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => {
const association = linkCodes.associationFor(linkCode);
if (association) {
return {
getDeviceAuthTokenResult: {
authToken: association.authToken.value,
privateKey: association.authToken.version,
userInfo: {
nickname: association.nickname,
userIdHashCode: crypto
.createHash("sha256")
.update(association.userId)
.digest("hex"),
},
},
};
} else {
throw {
Fault: {
faultcode: "Client.NOT_LINKED_RETRY",
faultstring: "Link Code not found retry...",
detail: {
ExceptionInfo: "NOT_LINKED_RETRY",
SonosError: "5",
},
},
};
}
},
getSessionId: ({
username,
}: {
username: string;
password: string;
}) => {
return Promise.resolve({
username,
sessionId: "123",
});
},
},
},
};
const x = listen(
app,
SOAP_PATH,
sonosService,
readFileSync(WSDL_FILE, "utf8")
);
x.log = (type, data) => {
switch (type) {
case "info":
logger.info({ level: "info", data });
break;
case "warn":
logger.warn({ level: "warn", data });
break;
case "error":
logger.error({ level: "error", data });
break;
default:
logger.debug({ level: "debug", data });
}
};
return app; return app;
} }

View File

@@ -1,2 +1,134 @@
import crypto from "crypto";
import { Express } from "express";
import { listen } from "soap";
import { readFileSync } from "fs";
import path from "path";
import logger from "./logger";
export type GetAppLinkResult = { getAppLinkResult: { authorizeAccount: { appUrlStringId: string, deviceLink: { regUrl: string, linkCode:string,showLinkCode:boolean }} } } import { LinkCodes } from "./link_codes";
export const LOGIN_ROUTE = "/login"
export const SOAP_PATH = "/ws/sonos";
export const STRINGS_ROUTE = "/sonos/strings.xml";
export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml";
const WSDL_FILE = path.resolve(
__dirname,
"Sonoswsdl-1.19.4-20190411.142401-3.wsdl"
);
export type GetAppLinkResult = {
getAppLinkResult: {
authorizeAccount: {
appUrlStringId: string;
deviceLink: { regUrl: string; linkCode: string; showLinkCode: boolean };
};
};
};
export type GetDeviceAuthTokenResult = {
getDeviceAuthTokenResult: {
authToken: string;
privateKey: string;
userInfo: {
nickname: string;
userIdHashCode: string;
};
};
};
class SonosSoap {
linkCodes: LinkCodes;
webAddress: string;
constructor(webAddress: string, linkCodes: LinkCodes) {
this.webAddress = webAddress;
this.linkCodes = linkCodes;
}
getAppLink(): GetAppLinkResult {
const linkCode = this.linkCodes.mint();
return {
getAppLinkResult: {
authorizeAccount: {
appUrlStringId: "AppLinkMessage",
deviceLink: {
regUrl: `${this.webAddress}${LOGIN_ROUTE}?linkCode=${linkCode}`,
linkCode: linkCode,
showLinkCode: false,
},
},
},
};
}
getDeviceAuthToken({
linkCode,
}: {
linkCode: string;
}): GetDeviceAuthTokenResult {
const association = this.linkCodes.associationFor(linkCode);
if (association) {
return {
getDeviceAuthTokenResult: {
authToken: association.authToken.value,
privateKey: association.authToken.version,
userInfo: {
nickname: association.nickname,
userIdHashCode: crypto
.createHash("sha256")
.update(association.userId)
.digest("hex"),
},
},
};
} else {
throw {
Fault: {
faultcode: "Client.NOT_LINKED_RETRY",
faultstring: "Link Code not found retry...",
detail: {
ExceptionInfo: "NOT_LINKED_RETRY",
SonosError: "5",
},
},
};
}
}
}
function bindSmapiSoapServiceToExpress(app: Express, soapPath:string, webAddress: string, linkCodes: LinkCodes) {
const sonosSoap = new SonosSoap(webAddress, linkCodes);
const soapyService = listen(
app,
soapPath,
{
Sonos: {
SonosSoap: {
getAppLink: () => sonosSoap.getAppLink(),
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
sonosSoap.getDeviceAuthToken({ linkCode }),
},
},
},
readFileSync(WSDL_FILE, "utf8")
);
soapyService.log = (type, data) => {
switch (type) {
case "info":
logger.info({ level: "info", data });
break;
case "warn":
logger.warn({ level: "warn", data });
break;
case "error":
logger.error({ level: "error", data });
break;
default:
logger.debug({ level: "debug", data });
}
};
}
export default bindSmapiSoapServiceToExpress;

View File

@@ -5,10 +5,7 @@ import { MusicService } from "@svrooij/sonos/lib/services";
import { head } from "underscore"; import { head } from "underscore";
import logger from "./logger"; import logger from "./logger";
import STRINGS from './strings'; import STRINGS from './strings';
import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from './smapi';
export const SOAP_PATH = "/ws/sonos";
export const STRINGS_PATH = "/sonos/strings.xml";
export const PRESENTATION_MAP_PATH = "/sonos/presentationMap.xml";
export type Device = { export type Device = {
name: string; name: string;
@@ -42,11 +39,11 @@ export const bonobService = (
uri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`, uri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`,
secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`, secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`,
strings: { strings: {
uri: `${stripTailingSlash(bonobRoot)}${STRINGS_PATH}`, uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`,
version: STRINGS.version, version: STRINGS.version,
}, },
presentation: { presentation: {
uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_PATH}`, uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`,
version: "1", version: "1",
}, },
pollInterval: 1200, pollInterval: 1200,

View File

@@ -7,7 +7,8 @@ import * as xpath from "xpath-ts";
import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes"; import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes";
import makeServer from "../src/server"; import makeServer from "../src/server";
import { bonobService, SONOS_DISABLED, STRINGS_PATH } from "../src/sonos"; import { bonobService, SONOS_DISABLED } from "../src/sonos";
import { STRINGS_ROUTE, LOGIN_ROUTE } from "../src/smapi";
import { aService, getAppLinkMessage } from "./builders"; import { aService, getAppLinkMessage } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryMusicService } from "./in_memory_music_service";
@@ -26,7 +27,7 @@ describe("service config", () => {
); );
it("should return xml for the strings", async () => { it("should return xml for the strings", async () => {
const res = await request(server).get(STRINGS_PATH).send(); const res = await request(server).get(STRINGS_ROUTE).send();
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
@@ -61,7 +62,7 @@ describe("api", () => {
linkCodes linkCodes
); );
describe("/login", () => { describe(LOGIN_ROUTE, () => {
describe("when the credentials are valid", () => { describe("when the credentials are valid", () => {
it("should return 200 ok and have associated linkCode with user", async () => { it("should return 200 ok and have associated linkCode with user", async () => {
const username = "jane"; const username = "jane";
@@ -71,7 +72,7 @@ describe("api", () => {
musicService.hasUser({ username, password }); musicService.hasUser({ username, password });
const res = await request(server) const res = await request(server)
.post("/login") .post(LOGIN_ROUTE)
.type("form") .type("form")
.send({ username, password, linkCode }) .send({ username, password, linkCode })
.expect(200); .expect(200);
@@ -92,7 +93,7 @@ describe("api", () => {
musicService.hasNoUsers(); musicService.hasNoUsers();
const res = await request(server) const res = await request(server)
.post("/login") .post(LOGIN_ROUTE)
.type("form") .type("form")
.send({ username, password, linkCode }) .send({ username, password, linkCode })
.expect(403); .expect(403);
@@ -110,7 +111,7 @@ describe("api", () => {
musicService.hasUser({ username, password }); musicService.hasUser({ username, password });
const res = await request(server) const res = await request(server)
.post("/login") .post(LOGIN_ROUTE)
.type("form") .type("form")
.send({ username, password, linkCode }) .send({ username, password, linkCode })
.expect(400); .expect(400);
@@ -160,69 +161,71 @@ describe("api", () => {
}); });
}); });
}); });
});
describe("getDeviceAuthToken", () => { describe("getDeviceAuthToken", () => {
const linkCodes = new InMemoryLinkCodes(); const linkCodes = new InMemoryLinkCodes();
const server = makeServer( const server = makeServer(
SONOS_DISABLED, SONOS_DISABLED,
service, service,
rootUrl, rootUrl,
musicService, musicService,
linkCodes linkCodes
); );
describe("when there is a linkCode association", () => { describe("when there is a linkCode association", () => {
it("should return a device auth token", async () => { it("should return a device auth token", async () => {
const linkCode = linkCodes.mint(); const linkCode = linkCodes.mint();
const association = { authToken: { value: "at", version: "66" }, userId: "uid", nickname: "nn" }; const association = { authToken: { value: "at", version: "66" }, userId: "uid", nickname: "nn" };
linkCodes.associate(linkCode, association); linkCodes.associate(linkCode, association);
const ws = await createClientAsync(`${service.uri}?wsdl`, { const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri, endpoint: service.uri,
httpClient: supersoap(server, rootUrl), httpClient: supersoap(server, rootUrl),
});
const result = await ws.getDeviceAuthTokenAsync({ linkCode });
expect(result[0]).toEqual({
getDeviceAuthTokenResult: {
authToken: association.authToken.value,
privateKey: association.authToken.version,
userInfo: {
nickname: association.nickname,
userIdHashCode: crypto
.createHash("sha256")
.update(association.userId)
.digest("hex"),
},
},
});
});
});
describe("when there is no linkCode association", () => {
it("should return a device auth token", async () => {
const linkCode = "invalidLinkCode";
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getDeviceAuthTokenAsync({ linkCode })
.then(() => {
throw "Shouldnt get here";
})
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.NOT_LINKED_RETRY",
faultstring: "Link Code not found retry...",
detail: { ExceptionInfo: "NOT_LINKED_RETRY", SonosError: "5" },
});
}); });
const result = await ws.getDeviceAuthTokenAsync({ linkCode });
expect(result[0]).toEqual({
getDeviceAuthTokenResult: {
authToken: association.authToken.value,
privateKey: association.authToken.version,
userInfo: {
nickname: association.nickname,
userIdHashCode: crypto
.createHash("sha256")
.update(association.userId)
.digest("hex"),
},
},
});
});
});
describe("when there is no linkCode association", () => {
it("should return a device auth token", async () => {
const linkCode = "invalidLinkCode";
const ws = await createClientAsync(`${service.uri}?wsdl`, {
endpoint: service.uri,
httpClient: supersoap(server, rootUrl),
});
await ws
.getDeviceAuthTokenAsync({ linkCode })
.then(() => {
throw "Shouldnt get here";
})
.catch((e: any) => {
expect(e.root.Envelope.Body.Fault).toEqual({
faultcode: "Client.NOT_LINKED_RETRY",
faultstring: "Link Code not found retry...",
detail: { ExceptionInfo: "NOT_LINKED_RETRY", SonosError: "5" },
});
});
});
}); });
}); });
}); });
}); });

View File

@@ -1,39 +0,0 @@
import makeServer from "../src/server";
import { SONOS_DISABLED, SOAP_PATH } from "../src/sonos";
import { aService } from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from './supersoap';
import { createClientAsync } from "soap";
describe("ws", () => {
describe("can call getSessionId", () => {
it("should do something", async () => {
const WEB_ADDRESS = 'http://localhost:7653'
const server = makeServer(SONOS_DISABLED, aService(), WEB_ADDRESS, new InMemoryMusicService());
const { username, sessionId } = await createClientAsync(
`${WEB_ADDRESS}${SOAP_PATH}?wsdl`,
{
endpoint: `${WEB_ADDRESS}${SOAP_PATH}`,
httpClient: supersoap(server, WEB_ADDRESS),
}
).then((client) =>
client
.getSessionIdAsync({ username: "bob", password: "foo" })
.then(
([{ username, sessionId }]: [
{ username: string; sessionId: string }
]) => ({
username,
sessionId,
})
)
);
expect(username).toEqual("bob");
expect(sessionId).toEqual("123");
});
});
});

View File

@@ -2,7 +2,7 @@
<div id="content"> <div id="content">
<h1>Log in to <%= it.bonobService.name %></h1> <h1>Log in to <%= it.bonobService.name %></h1>
<form action="/login" method="POST"> <form action="<%= it.loginRoute %>" method="POST">
<label for="username">Username:</label><input type="text" id="username" name="username"><br> <label for="username">Username:</label><input type="text" id="username" name="username"><br>
<label for="password">Password:</label><input type="text" id="password" name="password"><br> <label for="password">Password:</label><input type="text" id="password" name="password"><br>
<input type="hidden" name="linkCode" value="<%= it.linkCode %>"> <input type="hidden" name="linkCode" value="<%= it.linkCode %>">