diff --git a/src/server.ts b/src/server.ts index 039fe95..4562bf1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,26 +1,17 @@ import express, { Express } from "express"; import * as Eta from "eta"; -import { listen } from "soap"; -import { readFileSync } from "fs"; -import path from "path"; import morgan from "morgan"; -import crypto from "crypto"; import { Sonos, Service, - SOAP_PATH, - STRINGS_PATH, - PRESENTATION_MAP_PATH, } from "./sonos"; +import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE, LOGIN_ROUTE } from './smapi'; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; 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( sonos: Sonos, @@ -70,14 +61,15 @@ function server( }); }); - app.get("/login", (req, res) => { + app.get(LOGIN_ROUTE, (req, res) => { res.render("login", { bonobService, 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 authResult = musicService.login({ username, @@ -101,7 +93,7 @@ function server( } }); - app.get(STRINGS_PATH, (_, res) => { + app.get(STRINGS_ROUTE, (_, res) => { res.type("application/xml").send(` @@ -111,94 +103,11 @@ function server( `); }); - app.get(PRESENTATION_MAP_PATH, (_, res) => { + app.get(PRESENTATION_MAP_ROUTE, (_, res) => { res.send(""); }); - const sonosService = { - 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 }); - } - }; + bindSmapiSoapServiceToExpress(app, SOAP_PATH, webAddress, linkCodes); return app; } diff --git a/src/smapi.ts b/src/smapi.ts index 57d599a..04a7c79 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -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; diff --git a/src/sonos.ts b/src/sonos.ts index b795d94..7ff44d8 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -5,10 +5,7 @@ import { MusicService } from "@svrooij/sonos/lib/services"; import { head } from "underscore"; import logger from "./logger"; import STRINGS from './strings'; - -export const SOAP_PATH = "/ws/sonos"; -export const STRINGS_PATH = "/sonos/strings.xml"; -export const PRESENTATION_MAP_PATH = "/sonos/presentationMap.xml"; +import { SOAP_PATH, STRINGS_ROUTE, PRESENTATION_MAP_ROUTE } from './smapi'; export type Device = { name: string; @@ -42,11 +39,11 @@ export const bonobService = ( uri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`, secureUri: `${stripTailingSlash(bonobRoot)}${SOAP_PATH}`, strings: { - uri: `${stripTailingSlash(bonobRoot)}${STRINGS_PATH}`, + uri: `${stripTailingSlash(bonobRoot)}${STRINGS_ROUTE}`, version: STRINGS.version, }, presentation: { - uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_PATH}`, + uri: `${stripTailingSlash(bonobRoot)}${PRESENTATION_MAP_ROUTE}`, version: "1", }, pollInterval: 1200, diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 0de929f..938cb15 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -7,7 +7,8 @@ import * as xpath from "xpath-ts"; import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes"; 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 { InMemoryMusicService } from "./in_memory_music_service"; @@ -26,7 +27,7 @@ describe("service config", () => { ); 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); @@ -61,7 +62,7 @@ describe("api", () => { linkCodes ); - describe("/login", () => { + describe(LOGIN_ROUTE, () => { describe("when the credentials are valid", () => { it("should return 200 ok and have associated linkCode with user", async () => { const username = "jane"; @@ -71,7 +72,7 @@ describe("api", () => { musicService.hasUser({ username, password }); const res = await request(server) - .post("/login") + .post(LOGIN_ROUTE) .type("form") .send({ username, password, linkCode }) .expect(200); @@ -92,7 +93,7 @@ describe("api", () => { musicService.hasNoUsers(); const res = await request(server) - .post("/login") + .post(LOGIN_ROUTE) .type("form") .send({ username, password, linkCode }) .expect(403); @@ -110,7 +111,7 @@ describe("api", () => { musicService.hasUser({ username, password }); const res = await request(server) - .post("/login") + .post(LOGIN_ROUTE) .type("form") .send({ username, password, linkCode }) .expect(400); @@ -160,69 +161,71 @@ describe("api", () => { }); }); }); - }); - describe("getDeviceAuthToken", () => { - const linkCodes = new InMemoryLinkCodes(); - const server = makeServer( - SONOS_DISABLED, - service, - rootUrl, - musicService, - linkCodes - ); - - describe("when there is a linkCode association", () => { - it("should return a device auth token", async () => { - const linkCode = linkCodes.mint(); - const association = { authToken: { value: "at", version: "66" }, userId: "uid", nickname: "nn" }; - linkCodes.associate(linkCode, association); - - const ws = await createClientAsync(`${service.uri}?wsdl`, { - endpoint: service.uri, - 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" }, - }); + describe("getDeviceAuthToken", () => { + const linkCodes = new InMemoryLinkCodes(); + const server = makeServer( + SONOS_DISABLED, + service, + rootUrl, + musicService, + linkCodes + ); + + describe("when there is a linkCode association", () => { + it("should return a device auth token", async () => { + const linkCode = linkCodes.mint(); + const association = { authToken: { value: "at", version: "66" }, userId: "uid", nickname: "nn" }; + linkCodes.associate(linkCode, association); + + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + 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" }, + }); + }); + }); }); }); + }); + }); diff --git a/tests/ws.test.ts b/tests/ws.test.ts deleted file mode 100644 index 5babe8a..0000000 --- a/tests/ws.test.ts +++ /dev/null @@ -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"); - }); - }); -}); diff --git a/web/views/login.eta b/web/views/login.eta index 18e3be3..3a4d3a7 100644 --- a/web/views/login.eta +++ b/web/views/login.eta @@ -2,7 +2,7 @@

Log in to <%= it.bonobService.name %>

-
+