Add groundwork for third-party integrations. Spotify is the first. (#34)

There is now an "integrations context" in the front-end which allows connections to be made to external third-party APIs.
A user settings window is added to edit the integrations for the current user.
The back-end stores secret data for the integration and proxies requests so that the front-end is able to use these APIs pseudo-directly.

The first example is the Spotify API using client credentials, which allows e.g. searching tracks and artists.
editsong
Sander Vocke 5 years ago
parent fc02f57893
commit dbd442d517
  1. 8
      client/package-lock.json
  2. 1
      client/package.json
  3. 5
      client/src/App.tsx
  4. 80
      client/src/api.ts
  5. 1
      client/src/assets/spotify_icon.svg
  6. 97
      client/src/components/MainWindow.tsx
  7. 8
      client/src/components/appbar/AppBar.tsx
  8. 4
      client/src/components/common/StoreLinkIcon.tsx
  9. 6
      client/src/components/windows/Windows.tsx
  10. 11
      client/src/components/windows/album/AlbumWindow.tsx
  11. 8
      client/src/components/windows/artist/ArtistWindow.tsx
  12. 7
      client/src/components/windows/manage_tags/ManageTagsWindow.tsx
  13. 330
      client/src/components/windows/settings/IntegrationSettings.tsx
  14. 59
      client/src/components/windows/settings/SettingsWindow.tsx
  15. 67
      client/src/lib/backend/integrations.tsx
  16. 9
      client/src/lib/backend/queries.tsx
  17. 24
      client/src/lib/backend/request.tsx
  18. 9
      client/src/lib/backend/tags.tsx
  19. 59
      client/src/lib/integration/Integration.tsx
  20. 95
      client/src/lib/integration/spotify/SpotifyClientCreds.tsx
  21. 161
      client/src/lib/integration/useIntegrations.tsx
  22. 9
      client/src/lib/saveChanges.tsx
  23. 33
      client/src/lib/useAuth.tsx
  24. 78
      server/app.ts
  25. 160
      server/endpoints/Album.ts
  26. 64
      server/endpoints/AlbumDetails.ts
  27. 112
      server/endpoints/Artist.ts
  28. 41
      server/endpoints/ArtistDetails.ts
  29. 96
      server/endpoints/CreateAlbum.ts
  30. 70
      server/endpoints/CreateArtist.ts
  31. 118
      server/endpoints/CreateSong.ts
  32. 62
      server/endpoints/CreateTag.ts
  33. 78
      server/endpoints/DeleteTag.ts
  34. 206
      server/endpoints/Integration.ts
  35. 78
      server/endpoints/MergeTag.ts
  36. 191
      server/endpoints/ModifySong.ts
  37. 66
      server/endpoints/ModifyTag.ts
  38. 2
      server/endpoints/Query.ts
  39. 2
      server/endpoints/RegisterUser.ts
  40. 372
      server/endpoints/Song.ts
  41. 68
      server/endpoints/SongDetails.ts
  42. 306
      server/endpoints/Tag.ts
  43. 34
      server/endpoints/TagDetails.ts
  44. 111
      server/integrations/integrations.ts
  45. 24
      server/migrations/20201113155620_add_integrations.ts
  46. 113
      server/package-lock.json
  47. 4
      server/package.json
  48. 127
      server/test/integration/flows/IntegrationFlow.js
  49. 89
      server/test/integration/flows/helpers.js

@ -11286,6 +11286,14 @@
"resolved": "https://registry.npmjs.org/react-double-scrollbar/-/react-double-scrollbar-0.0.15.tgz",
"integrity": "sha1-6RWrjLO5WYdwdfSUNt6/2wQoj+Q="
},
"react-error-boundary": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.0.2.tgz",
"integrity": "sha512-KVzCusRTFpUYG0OFJbzbdRuxNQOBiGXVCqyNpBXM9z5NFsFLzMjUXMjx8gTja6M6WH+D2PvP3yKz4d8gD1PRaA==",
"requires": {
"@babel/runtime": "^7.11.2"
}
},
"react-error-overlay": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.7.tgz",

@ -24,6 +24,7 @@
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^16.13.1",
"react-error-boundary": "^3.0.2",
"react-router-dom": "^5.2.0",
"react-scripts": "^3.4.3",
"typescript": "~3.7.2",

@ -5,13 +5,12 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
import MainWindow from './components/MainWindow';
import { ProvideAuth } from './lib/useAuth';
import { ProvideIntegrations } from './lib/integration/useIntegrations';
function App() {
return (
<DndProvider backend={HTML5Backend}>
<ProvideAuth>
<MainWindow />
</ProvideAuth>
<MainWindow />
</DndProvider>
);
}

@ -356,4 +356,82 @@ export function checkRegisterUserRequest(req: any): boolean {
// Note: Login is handled by Passport.js, so it is not explicitly written here.
export const LoginEndpoint = "/login";
export const LogoutEndpoint = "/logout";
export const LogoutEndpoint = "/logout";
export enum IntegrationType {
SpotifyClientCredentials = "SpotifyClientCredentials",
}
export interface SpotifyClientCredentialsDetails {
clientId: string,
}
export interface SpotifyClientCredentialsSecretDetails {
clientSecret: string,
}
export type IntegrationDetails = SpotifyClientCredentialsDetails;
export type IntegrationSecretDetails = SpotifyClientCredentialsSecretDetails;
// Create a new integration (POST).
export const CreateIntegrationEndpoint = '/integration';
export interface CreateIntegrationRequest {
name: string,
type: IntegrationType,
details: IntegrationDetails,
secretDetails: IntegrationSecretDetails,
}
export interface CreateIntegrationResponse {
id: number;
}
export function checkCreateIntegrationRequest(req: any): boolean {
return "body" in req &&
"name" in req.body &&
"type" in req.body &&
"details" in req.body &&
"secretDetails" in req.body &&
(req.body.type in IntegrationType);
}
// Modify an existing integration (PUT).
export const ModifyIntegrationEndpoint = '/integration/:id';
export interface ModifyIntegrationRequest {
name?: string,
type?: IntegrationType,
details?: IntegrationDetails,
secretDetails?: IntegrationSecretDetails,
}
export interface ModifyIntegrationResponse { }
export function checkModifyIntegrationRequest(req: any): boolean {
if("type" in req.body && !(req.body.type in IntegrationType)) return false;
return true;
}
// Get integration details (GET).
export const IntegrationDetailsEndpoint = '/integration/:id';
export interface IntegrationDetailsRequest { }
export interface IntegrationDetailsResponse {
name: string,
type: IntegrationType,
details: IntegrationDetails,
}
export function checkIntegrationDetailsRequest(req: any): boolean {
return true;
}
// List integrations (GET).
export const ListIntegrationsEndpoint = '/integration';
export interface ListIntegrationsRequest { }
export interface ListIntegrationsItem extends IntegrationDetailsResponse { id: number }
export type ListIntegrationsResponse = ListIntegrationsItem[];
export function checkListIntegrationsRequest(req: any): boolean {
return true;
}
// Delete integration (DELETE).
export const DeleteIntegrationEndpoint = '/integration/:id';
export interface DeleteIntegrationRequest { }
export interface DeleteIntegrationResponse { }
export function checkDeleteIntegrationRequest(req: any): boolean {
return true;
}

@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2931 2931" width="2931" height="2931"><style>.st0{fill:#2ebd59}</style><path class="st0" d="M1465.5 0C656.1 0 0 656.1 0 1465.5S656.1 2931 1465.5 2931 2931 2274.9 2931 1465.5C2931 656.2 2274.9.1 1465.5 0zm672.1 2113.6c-26.3 43.2-82.6 56.7-125.6 30.4-344.1-210.3-777.3-257.8-1287.4-141.3-49.2 11.3-98.2-19.5-109.4-68.7-11.3-49.2 19.4-98.2 68.7-109.4C1242.1 1697.1 1721 1752 2107.3 1988c43 26.5 56.7 82.6 30.3 125.6zm179.3-398.9c-33.1 53.8-103.5 70.6-157.2 37.6-393.8-242.1-994.4-312.2-1460.3-170.8-60.4 18.3-124.2-15.8-142.6-76.1-18.2-60.4 15.9-124.1 76.2-142.5 532.2-161.5 1193.9-83.3 1646.2 194.7 53.8 33.1 70.8 103.4 37.7 157.1zm15.4-415.6c-472.4-280.5-1251.6-306.3-1702.6-169.5-72.4 22-149-18.9-170.9-91.3-21.9-72.4 18.9-149 91.4-171 517.7-157.1 1378.2-126.8 1922 196 65.1 38.7 86.5 122.8 47.9 187.8-38.5 65.2-122.8 86.7-187.8 48z"/></svg>

After

Width:  |  Height:  |  Size: 907 B

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { ThemeProvider, CssBaseline, createMuiTheme } from '@material-ui/core';
import { grey } from '@material-ui/core/colors';
import AppBar, { AppBarTab } from './appbar/AppBar';
@ -8,10 +8,13 @@ import AlbumWindow from './windows/album/AlbumWindow';
import TagWindow from './windows/tag/TagWindow';
import SongWindow from './windows/song/SongWindow';
import ManageTagsWindow from './windows/manage_tags/ManageTagsWindow';
import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom';
import { BrowserRouter, Switch, Route, Redirect, useHistory } from 'react-router-dom';
import LoginWindow from './windows/login/LoginWindow';
import { useAuth } from '../lib/useAuth';
import { useAuth, ProvideAuth } from '../lib/useAuth';
import RegisterWindow from './windows/register/RegisterWindow';
import SettingsWindow from './windows/settings/SettingsWindow';
import { ErrorBoundary } from 'react-error-boundary';
import { ProvideIntegrations } from '../lib/integration/useIntegrations';
const darkTheme = createMuiTheme({
palette: {
@ -44,44 +47,52 @@ function PrivateRoute(props: any) {
export default function MainWindow(props: any) {
return <ThemeProvider theme={darkTheme}>
<CssBaseline />
<BrowserRouter>
<Switch>
<Route exact path="/">
<Redirect to={"/query"} />
</Route>
<Route path="/login">
<AppBar selectedTab={null} />
<LoginWindow />
</Route>
<Route path="/register">
<AppBar selectedTab={null} />
<RegisterWindow />
</Route>
<PrivateRoute path="/query">
<AppBar selectedTab={AppBarTab.Query} />
<QueryWindow />
</PrivateRoute>
<PrivateRoute path="/artist/:id">
<AppBar selectedTab={null} />
<ArtistWindow />
</PrivateRoute>
<PrivateRoute path="/tag/:id">
<AppBar selectedTab={null} />
<TagWindow />
</PrivateRoute>
<PrivateRoute path="/album/:id">
<AppBar selectedTab={null} />
<AlbumWindow />
</PrivateRoute>
<PrivateRoute path="/song/:id">
<AppBar selectedTab={null} />
<SongWindow />
</PrivateRoute>
<PrivateRoute path="/tags">
<AppBar selectedTab={AppBarTab.Tags} />
<ManageTagsWindow />
</PrivateRoute>
</Switch>
</BrowserRouter>
</ThemeProvider>
<ProvideAuth>
<ProvideIntegrations>
<BrowserRouter>
<Switch>
<Route exact path="/">
<Redirect to={"/query"} />
</Route>
<Route path="/login">
<AppBar selectedTab={null} />
<LoginWindow />
</Route>
<Route path="/register">
<AppBar selectedTab={null} />
<RegisterWindow />
</Route>
<PrivateRoute path="/settings">
<AppBar selectedTab={null} />
<SettingsWindow />
</PrivateRoute>
<PrivateRoute path="/query">
<AppBar selectedTab={AppBarTab.Query} />
<QueryWindow />
</PrivateRoute>
<PrivateRoute path="/artist/:id">
<AppBar selectedTab={null} />
<ArtistWindow />
</PrivateRoute>
<PrivateRoute path="/tag/:id">
<AppBar selectedTab={null} />
<TagWindow />
</PrivateRoute>
<PrivateRoute path="/album/:id">
<AppBar selectedTab={null} />
<AlbumWindow />
</PrivateRoute>
<PrivateRoute path="/song/:id">
<AppBar selectedTab={null} />
<SongWindow />
</PrivateRoute>
<PrivateRoute path="/tags">
<AppBar selectedTab={AppBarTab.Tags} />
<ManageTagsWindow />
</PrivateRoute>
</Switch>
</BrowserRouter>
</ProvideIntegrations>
</ProvideAuth>
</ThemeProvider >
}

@ -28,6 +28,8 @@ export function UserMenu(props: {
onClose: () => void,
}) {
let auth = useAuth();
let history = useHistory();
const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] }
: { left: 0, top: 0 }
@ -41,6 +43,12 @@ export function UserMenu(props: {
>
<Box p={2}>
{auth.user?.email || "Unknown user"}
<MenuItem
onClick={() => {
props.onClose();
history.replace('/settings')
}}
>User Settings</MenuItem>
<MenuItem
onClick={() => {
props.onClose();

@ -1,8 +1,10 @@
import React from 'react';
import { ReactComponent as GPMIcon } from '../../assets/googleplaymusic_icon.svg';
import { ReactComponent as SpotifyIcon } from '../../assets/spotify_icon.svg';
export enum ExternalStore {
GooglePlayMusic = "GPM",
Spotify = "Spotify",
}
export interface IProps {
@ -22,6 +24,8 @@ export default function StoreLinkIcon(props: any) {
switch(whichStore) {
case ExternalStore.GooglePlayMusic:
return <GPMIcon {...restProps}/>;
case ExternalStore.Spotify:
return <SpotifyIcon {...restProps}/>;
default:
throw new Error("Unknown external store: " + whichStore)
}

@ -14,6 +14,7 @@ import { songGetters } from '../../lib/songGetters';
import ManageTagsWindow, { ManageTagsWindowReducer } from './manage_tags/ManageTagsWindow';
import { RegisterWindowReducer } from './register/RegisterWindow';
import { LoginWindowReducer } from './login/LoginWindow';
import { SettingsWindowReducer } from './settings/SettingsWindow';
export enum WindowType {
Query = "Query",
@ -24,6 +25,7 @@ export enum WindowType {
ManageTags = "ManageTags",
Login = "Login",
Register = "Register",
Settings = "Settings",
}
export interface WindowState { }
@ -37,6 +39,7 @@ export const newWindowReducer = {
[WindowType.ManageTags]: ManageTagsWindowReducer,
[WindowType.Login]: LoginWindowReducer,
[WindowType.Register]: RegisterWindowReducer,
[WindowType.Settings]: SettingsWindowReducer,
}
export const newWindowState = {
@ -94,4 +97,7 @@ export const newWindowState = {
[WindowType.Register]: () => {
return {}
},
[WindowType.Settings]: () => {
return {}
},
}

@ -12,6 +12,8 @@ import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryAlbums, querySongs } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters';
import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
export type AlbumMetadata = serverApi.AlbumDetails;
export type AlbumMetadataChanges = serverApi.ModifyAlbumRequest;
@ -55,7 +57,8 @@ export async function getAlbumMetadata(id: number) {
},
offset: 0,
limit: 1,
}))[0];
})
)[0];
}
export default function AlbumWindow(props: {}) {
@ -77,6 +80,7 @@ export function AlbumWindowControlled(props: {
}) {
let { id: albumId, metadata, pendingChanges, songsOnAlbum } = props.state;
let { dispatch } = props;
let auth = useAuth();
// Effect to get the album's metadata.
useEffect(() => {
@ -87,6 +91,7 @@ export function AlbumWindowControlled(props: {
value: m
});
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}, [albumId, dispatch]);
// Effect to get the album's songs.
@ -102,7 +107,8 @@ export function AlbumWindowControlled(props: {
},
offset: 0,
limit: -1,
});
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) });
dispatch({
type: AlbumWindowStateActions.SetSongs,
value: songs,
@ -153,6 +159,7 @@ export function AlbumWindowControlled(props: {
type: AlbumWindowStateActions.Reload
})
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>

@ -12,6 +12,8 @@ import { QueryLeafBy, QueryLeafOp } from '../../../lib/query/Query';
import { queryArtists, querySongs } from '../../../lib/backend/queries';
import { songGetters } from '../../../lib/songGetters';
import { useParams } from 'react-router';
import { handleNotLoggedIn, NotLoggedInError } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
export type ArtistMetadata = serverApi.ArtistDetails;
export type ArtistMetadataChanges = serverApi.ModifyArtistRequest;
@ -82,6 +84,7 @@ export function ArtistWindowControlled(props: {
}) {
let { metadata, id: artistId, pendingChanges, songsByArtist } = props.state;
let { dispatch } = props;
let auth = useAuth();
// Effect to get the artist's metadata.
useEffect(() => {
@ -92,6 +95,7 @@ export function ArtistWindowControlled(props: {
value: m
});
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}, [artistId, dispatch]);
// Effect to get the artist's songs.
@ -107,7 +111,8 @@ export function ArtistWindowControlled(props: {
},
offset: 0,
limit: -1,
});
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) });
dispatch({
type: ArtistWindowStateActions.SetSongs,
value: songs,
@ -158,6 +163,7 @@ export function ArtistWindowControlled(props: {
type: ArtistWindowStateActions.Reload
})
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
}} />
{applying && <CircularProgress />}
</Box>

@ -11,6 +11,8 @@ import NewTagMenu from './NewTagMenu';
import { v4 as genUuid } from 'uuid';
import Alert from '@material-ui/lab/Alert';
import { useHistory } from 'react-router';
import { NotLoggedInError, handleNotLoggedIn } from '../../../lib/backend/request';
import { useAuth } from '../../../lib/useAuth';
var _ = require('lodash');
export interface ManageTagsWindowState extends WindowState {
@ -355,6 +357,7 @@ export function ManageTagsWindowControlled(props: {
const [newTagMenuPos, setNewTagMenuPos] = React.useState<null | number[]>(null);
let { fetchedTags } = props.state;
let { dispatch } = props;
let auth = useAuth();
const onOpenNewTagMenu = (e: any) => {
setNewTagMenuPos([e.clientX, e.clientY])
@ -422,7 +425,9 @@ export function ManageTagsWindowControlled(props: {
props.dispatch({
type: ManageTagsWindowActions.Reset
});
}).catch((e: Error) => {
})
.catch((e: NotLoggedInError) => { handleNotLoggedIn(auth, e) })
.catch((e: Error) => {
props.dispatch({
type: ManageTagsWindowActions.SetAlert,
value: <Alert severity="error">Failed to save changes: {e.message}</Alert>,

@ -0,0 +1,330 @@
import React, { useState, useEffect } from 'react';
import { Box, CircularProgress, IconButton, Typography, MenuItem, TextField, Menu, Button, Card, CardHeader, CardContent, CardActions, Dialog, DialogTitle } from '@material-ui/core';
import { getIntegrations, createIntegration, modifyIntegration, deleteIntegration } from '../../../lib/backend/integrations';
import AddIcon from '@material-ui/icons/Add';
import EditIcon from '@material-ui/icons/Edit';
import CheckIcon from '@material-ui/icons/Check';
import DeleteIcon from '@material-ui/icons/Delete';
import ClearIcon from '@material-ui/icons/Clear';
import * as serverApi from '../../../api';
import { v4 as genUuid } from 'uuid';
import { useIntegrations, IntegrationClasses, IntegrationState, isIntegrationState, makeDefaultIntegrationProperties, makeIntegration } from '../../../lib/integration/useIntegrations';
import Alert from '@material-ui/lab/Alert';
import Integration from '../../../lib/integration/Integration';
let _ = require('lodash')
// This widget is used to either display or edit a few
// specifically needed for Spotify Client credentials integration.
function EditSpotifyClientCredentialsDetails(props: {
clientId: string,
clientSecret: string | null,
editing: boolean,
onChangeClientId: (v: string) => void,
onChangeClientSecret: (v: string) => void,
}) {
return <Box>
<Box mt={1} mb={1}>
<TextField
variant="outlined"
disabled={!props.editing}
value={props.clientId || ""}
label="Client id"
fullWidth
onChange={(e: any) => props.onChangeClientId(e.target.value)}
/>
</Box>
<Box mt={1} mb={1}>
<TextField
variant="outlined"
disabled={!props.editing}
value={props.clientSecret === null ? "••••••••••••••••" : props.clientSecret}
label="Client secret"
fullWidth
onChange={(e: any) => {
props.onChangeClientSecret(e.target.value)
}}
onFocus={(e: any) => {
if (props.clientSecret === null) {
// Change from dots to empty input
console.log("Focus!")
props.onChangeClientSecret('');
}
}}
/>
</Box>
</Box>;
}
// An editing widget which is meant to either display or edit properties
// of an integration.
function EditIntegration(props: {
upstreamId?: number,
integration: serverApi.CreateIntegrationRequest,
editing?: boolean,
showSubmitButton?: boolean | "InProgress",
showDeleteButton?: boolean | "InProgress",
showEditButton?: boolean,
showTestButton?: boolean | "InProgress",
showCancelButton?: boolean,
flashMessage?: React.ReactFragment,
isNew: boolean,
onChange?: (p: serverApi.CreateIntegrationRequest) => void,
onSubmit?: (p: serverApi.CreateIntegrationRequest) => void,
onDelete?: () => void,
onEdit?: () => void,
onTest?: () => void,
onCancel?: () => void,
}) {
let IntegrationHeaders: Record<any, any> = {
[serverApi.IntegrationType.SpotifyClientCredentials]:
<Box display="flex" alignItems="center">
<Box mr={1}>
{IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials].getIcon({
style: { height: '40px', width: '40px' }
})}
</Box>
<Typography>Spotify (using Client Credentials)</Typography>
</Box>
}
let IntegrationDescription: Record<any, any> = {
[serverApi.IntegrationType.SpotifyClientCredentials]:
<Typography>
This integration allows using the Spotify API to make requests that are
tied to any specific user, such as searching items and retrieving item
metadata.<br />
Please see the Spotify API documentation on how to generate a client ID
and client secret. Once set, you will only be able to overwrite the secret
here, not read it.
</Typography>
}
return <Card variant="outlined">
<CardHeader
avatar={
IntegrationHeaders[props.integration.type]
}
>
</CardHeader>
<CardContent>
<Box mb={2}>{IntegrationDescription[props.integration.type]}</Box>
<Box mt={1} mb={1}>
<TextField
variant="outlined"
value={props.integration.name || ""}
label="Integration name"
fullWidth
disabled={!props.editing}
onChange={(e: any) => props.onChange && props.onChange({
...props.integration,
name: e.target.value,
})}
/>
</Box>
{props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials &&
<EditSpotifyClientCredentialsDetails
clientId={props.integration.details.clientId}
clientSecret={props.integration.secretDetails ?
props.integration.secretDetails.clientSecret :
(props.isNew ? "" : null)}
editing={props.editing || false}
onChangeClientId={(v: string) => props.onChange && props.onChange({
...props.integration,
details: {
...props.integration.details,
clientId: v,
}
})}
onChangeClientSecret={(v: string) => props.onChange && props.onChange({
...props.integration,
secretDetails: {
...props.integration.secretDetails,
clientSecret: v,
}
})}
/>
}
{props.flashMessage && props.flashMessage}
</CardContent>
<CardActions>
{props.showEditButton && <IconButton
onClick={props.onEdit}
><EditIcon /></IconButton>}
{props.showSubmitButton && <IconButton
onClick={() => props.onSubmit && props.onSubmit(props.integration)}
><CheckIcon /></IconButton>}
{props.showDeleteButton && <IconButton
onClick={props.onDelete}
><DeleteIcon /></IconButton>}
{props.showCancelButton && <IconButton
onClick={props.onCancel}
><ClearIcon /></IconButton>}
{props.showTestButton && <Button
onClick={props.onTest}
>Test</Button>}
</CardActions>
</Card>
}
let EditorWithTest = (props: any) => {
const [testFlashMessage, setTestFlashMessage] =
React.useState<React.ReactFragment | undefined>(undefined);
let { integration, ...rest } = props;
return <EditIntegration
onTest={() => {
integration.integration.test({})
.then(() => {
setTestFlashMessage(
<Alert severity="success">Integration is active.</Alert>
)
})
}}
flashMessage={testFlashMessage}
showTestButton={true}
integration={integration.properties}
{...rest}
/>;
}
function AddIntegrationMenu(props: {
position: null | number[],
open: boolean,
onClose?: () => void,
onAdd?: (type: serverApi.IntegrationType) => void,
}) {
const pos = props.open && props.position ?
{ left: props.position[0], top: props.position[1] }
: { left: 0, top: 0 }
return <Menu
open={props.open}
anchorReference="anchorPosition"
anchorPosition={pos}
keepMounted
onClose={props.onClose}
>
<MenuItem
onClick={() => {
props.onAdd && props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials);
props.onClose && props.onClose();
}}
>Spotify</MenuItem>
</Menu>
}
function EditIntegrationDialog(props: {
open: boolean,
onClose?: () => void,
upstreamId?: number,
integration: IntegrationState,
onSubmit?: (p: serverApi.CreateIntegrationRequest) => void,
isNew: boolean,
}) {
let [editingIntegration, setEditingIntegration] =
useState<IntegrationState>(props.integration);
useEffect(() => { setEditingIntegration(props.integration); }, [props.integration]);
return <Dialog
onClose={props.onClose}
open={props.open}
disableBackdropClick={true}
>
<DialogTitle>Edit Integration</DialogTitle>
<EditIntegration
isNew={props.isNew}
editing={true}
upstreamId={props.upstreamId}
integration={editingIntegration.properties}
showCancelButton={true}
showSubmitButton={props.onSubmit !== undefined}
showTestButton={false}
onCancel={props.onClose}
onSubmit={props.onSubmit}
onChange={(i: any) => {
setEditingIntegration({
...editingIntegration,
properties: i,
integration: makeIntegration(i, editingIntegration.id),
});
}}
/>
</Dialog>
}
export default function IntegrationSettings(props: {}) {
const [addMenuPos, setAddMenuPos] = React.useState<null | number[]>(null);
const [editingState, setEditingState] = React.useState<IntegrationState | null>(null);
let {
state: integrations,
addIntegration,
modifyIntegration,
deleteIntegration,
updateFromUpstream,
} = useIntegrations();
const onOpenAddMenu = (e: any) => {
setAddMenuPos([e.clientX, e.clientY])
};
const onCloseAddMenu = () => {
setAddMenuPos(null);
};
return <>
<Box>
{integrations === null && <CircularProgress />}
{Array.isArray(integrations) && <Box display="flex" flexDirection="column" alignItems="center" flexWrap="wrap">
{integrations.map((state: IntegrationState) => <Box m={1} width="90%">
<EditorWithTest
upstreamId={state.id}
integration={state}
showEditButton={true}
showDeleteButton={true}
onEdit={() => { setEditingState(state); }}
onDelete={() => {
deleteIntegration(state.id)
.then(updateFromUpstream)
}}
/>
</Box>)}
<IconButton onClick={onOpenAddMenu}>
<AddIcon />
</IconButton>
</Box>}
</Box>
<AddIntegrationMenu
position={addMenuPos}
open={addMenuPos !== null}
onClose={onCloseAddMenu}
onAdd={(type: serverApi.IntegrationType) => {
let p = makeDefaultIntegrationProperties(type);
setEditingState({
properties: p,
integration: makeIntegration(p, -1),
id: -1,
})
}}
/>
{editingState && <EditIntegrationDialog
open={!(editingState === null)}
onClose={() => { setEditingState(null); }}
integration={editingState}
isNew={editingState.id === -1}
onSubmit={(v: serverApi.CreateIntegrationRequest) => {
if (editingState.id >= 0) {
const id = editingState.id;
setEditingState(null);
modifyIntegration(id, v)
.then(updateFromUpstream)
} else {
setEditingState(null);
createIntegration({
...v,
secretDetails: v.secretDetails || {},
})
.then(updateFromUpstream)
}
}}
/>}
</>;
}

@ -0,0 +1,59 @@
import React, { useReducer } from 'react';
import { WindowState } from "../Windows";
import { Box, Paper, Typography, TextField, Button } from "@material-ui/core";
import { useHistory } from 'react-router';
import { useAuth, Auth } from '../../../lib/useAuth';
import Alert from '@material-ui/lab/Alert';
import { Link } from 'react-router-dom';
import IntegrationSettingsEditor from './IntegrationSettings';
export enum SettingsTab {
Integrations = 0,
}
export interface SettingsWindowState extends WindowState {
activeTab: SettingsTab,
}
export enum SettingsWindowStateActions {
SetActiveTab = "SetActiveTab",
}
export function SettingsWindowReducer(state: SettingsWindowState, action: any) {
switch (action.type) {
case SettingsWindowStateActions.SetActiveTab:
return { ...state, activeTab: action.value }
default:
throw new Error("Unimplemented SettingsWindow state update.")
}
}
export default function SettingsWindow(props: {}) {
const [state, dispatch] = useReducer(SettingsWindowReducer, {
activeTab: SettingsTab.Integrations,
});
return <SettingsWindowControlled state={state} dispatch={dispatch} />
}
export function SettingsWindowControlled(props: {
state: SettingsWindowState,
dispatch: (action: any) => void,
}) {
let history: any = useHistory();
let auth: Auth = useAuth();
return <Box width="100%" justifyContent="center" display="flex" flexWrap="wrap">
<Box
m={1}
mt={4}
width="60%"
>
<Paper>
<Box p={3}>
<Box mb={3}><Typography variant="h5">User Settings</Typography></Box>
<Typography variant="h6">Integrations</Typography>
<IntegrationSettingsEditor/>
</Box>
</Paper>
</Box>
</Box>
}

@ -0,0 +1,67 @@
import * as serverApi from '../../api';
import { useAuth } from '../useAuth';
import backendRequest from './request';
export async function createIntegration(details: serverApi.CreateIntegrationRequest) {
const requestOpts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
};
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateIntegrationEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to integration creation not OK: " + JSON.stringify(response));
}
return await response.json();
}
export async function modifyIntegration(id: number, details: serverApi.ModifyIntegrationRequest) {
const requestOpts = {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(details),
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ModifyIntegrationEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to integration modification not OK: " + JSON.stringify(response));
}
}
export async function deleteIntegration(id: number) {
const requestOpts = {
method: 'DELETE',
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.DeleteIntegrationEndpoint.replace(':id', id.toString()),
requestOpts
);
if (!response.ok) {
throw new Error("Response to integration deletion not OK: " + JSON.stringify(response));
}
}
export async function getIntegrations() {
const requestOpts = {
method: 'GET',
};
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ListIntegrationsEndpoint,
requestOpts
);
if (!response.ok) {
throw new Error("Response to integration list not OK: " + JSON.stringify(response));
}
let json = await response.json();
return json;
}

@ -1,5 +1,6 @@
import * as serverApi from '../../api';
import { QueryElem, toApiQuery } from '../query/Query';
import backendRequest from './request';
export interface QueryArgs {
query?: QueryElem,
@ -29,7 +30,7 @@ export async function queryArtists(args: QueryArgs) {
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
return json.artists;
})();
@ -57,7 +58,7 @@ export async function queryAlbums(args: QueryArgs) {
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
return json.albums;
})();
@ -85,7 +86,7 @@ export async function querySongs(args: QueryArgs) {
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
let json: any = await response.json();
return json.songs;
})();
@ -113,7 +114,7 @@ export async function queryTags(args: QueryArgs) {
};
return (async () => {
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.QueryEndpoint, requestOpts);
let json: any = await response.json();
const tags = json.tags;

@ -0,0 +1,24 @@
import { ResponsiveFontSizesOptions } from "@material-ui/core/styles/responsiveFontSizes";
import { useHistory } from "react-router";
import { Auth } from "../useAuth";
export class NotLoggedInError extends Error {
constructor(message: string) {
super(message);
this.name = "NotLoggedInError";
}
}
export default async function backendRequest(url: any, ...restArgs: any[]): Promise<Response> {
let response = await fetch(url, ...restArgs);
if (response.status === 401 && (await response.json()).reason === "NotLoggedIn") {
console.log("Not logged in!")
throw new NotLoggedInError("Not logged in.");
}
return response;
}
export function handleNotLoggedIn(auth: Auth, e: NotLoggedInError) {
console.log("Not logged in!")
auth.signout();
}

@ -1,4 +1,5 @@
import * as serverApi from '../../api';
import backendRequest from './request';
export async function createTag(details: serverApi.CreateTagRequest) {
const requestOpts = {
@ -7,7 +8,7 @@ export async function createTag(details: serverApi.CreateTagRequest) {
body: JSON.stringify(details),
};
const response = await fetch((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + serverApi.CreateTagEndpoint, requestOpts)
if (!response.ok) {
throw new Error("Response to tag creation not OK: " + JSON.stringify(response));
}
@ -21,7 +22,7 @@ export async function modifyTag(id: number, details: serverApi.ModifyTagRequest)
body: JSON.stringify(details),
};
const response = await fetch(
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.ModifyTagEndpoint.replace(':id', id.toString()),
requestOpts
);
@ -35,7 +36,7 @@ export async function deleteTag(id: number) {
method: 'DELETE',
};
const response = await fetch(
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.DeleteTagEndpoint.replace(':id', id.toString()),
requestOpts
);
@ -49,7 +50,7 @@ export async function mergeTag(fromId: number, toId: number) {
method: 'POST',
};
const response = await fetch(
const response = await backendRequest(
(process.env.REACT_APP_BACKEND || "") + serverApi.MergeTagEndpoint
.replace(':id', fromId.toString())
.replace(':toId', toId.toString()),

@ -0,0 +1,59 @@
import React, { ReactFragment } from 'react';
export interface IntegrationAlbum {
name?: string,
artist?: IntegrationArtist,
url?: string, // An URL to access the item externally.
}
export interface IntegrationArtist {
name?: string,
url?: string, // An URL to access the item externally.
}
export interface IntegrationSong {
title?: string,
album?: IntegrationAlbum,
artist?: IntegrationArtist,
url?: string, // An URL to access the item externally.
}
export enum IntegrationFeature {
// Used to test whether the integration is active.
Test = 0,
// Used to get a bucket of songs (typically: the whole library)
GetSongs,
// Used to search items and get some amount of candidate results.
SearchSong,
SearchAlbum,
SearchArtist,
}
export interface IntegrationDescriptor {
supports: IntegrationFeature[],
}
export default class Integration {
constructor(integrationId: number) { }
// Common
static getFeatures(): IntegrationFeature[] { return []; }
static getIcon(props: any): ReactFragment { return <></> }
// Requires feature: Test
async test(testParams: any): Promise<void> {}
// Requires feature: GetSongs
async getSongs(getSongsParams: any): Promise<IntegrationSong[]> { return []; }
// Requires feature: SearchSongs
async searchSong(songProps: IntegrationSong): Promise<IntegrationSong[]> { return []; }
// Requires feature: SearchAlbum
async searchAlbum(albumProps: IntegrationAlbum): Promise<IntegrationAlbum[]> { return []; }
// Requires feature: SearchArtist
async searchArtist(artistProps: IntegrationArtist): Promise<IntegrationArtist[]> { return []; }
}

@ -0,0 +1,95 @@
import React from 'react';
import Integration, { IntegrationFeature, IntegrationAlbum, IntegrationArtist, IntegrationSong } from '../Integration';
import StoreLinkIcon, { ExternalStore } from '../../../components/common/StoreLinkIcon';
enum SearchType {
Song = 'song',
Artist = 'artist',
Album = 'album',
};
export default class SpotifyClientCreds extends Integration {
integrationId: number;
constructor(integrationId: number) {
super(integrationId);
this.integrationId = integrationId;
}
static getFeatures(): IntegrationFeature[] {
return [
IntegrationFeature.Test,
IntegrationFeature.SearchSong,
IntegrationFeature.SearchAlbum,
IntegrationFeature.SearchArtist,
]
}
static getIcon(props: any) {
return <StoreLinkIcon whichStore={ExternalStore.Spotify} {...props} />
}
async test(testParams: {}) {
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/v1/search?q=queens&type=artist`);
if (!response.ok) {
throw new Error("Spttify Client Credentails test failed: " + JSON.stringify(response));
}
}
async searchSong(songProps: IntegrationSong): Promise<IntegrationSong[]> { return []; }
async searchAlbum(albumProps: IntegrationAlbum): Promise<IntegrationAlbum[]> { return []; }
async searchArtist(artistProps: IntegrationArtist): Promise<IntegrationArtist[]> { return []; }
async search(query: string, type: SearchType):
Promise<IntegrationSong[] | IntegrationAlbum[] | IntegrationArtist[]> {
const response = await fetch(
(process.env.REACT_APP_BACKEND || "") +
`/integrations/${this.integrationId}/v1/search?q=${encodeURIComponent(query)}&type=${type}`);
if (!response.ok) {
throw new Error("Spotify Client Credentails search failed: " + JSON.stringify(response));
}
switch(type) {
case SearchType.Song: {
return (await response.json()).tracks.items.map((r: any): IntegrationSong => {
return {
title: r.name,
url: r.external_urls.spotify,
artist: {
name: r.artists[0].name,
url: r.artists[0].external_urls.spotify,
},
album: {
name: r.albums[0].name,
url: r.albums[0].external_urls.spotify,
}
}
})
}
case SearchType.Artist: {
return (await response.json()).artists.items.map((r: any): IntegrationArtist => {
return {
name: r.name,
url: r.external_urls.spotify,
}
})
}
case SearchType.Album: {
return (await response.json()).albums.items.map((r: any): IntegrationAlbum => {
return {
name: r.name,
url: r.external_urls.spotify,
artist: {
name: r.artists[0].name,
url: r.artists[0].external_urls.spotify,
},
}
})
}
}
}
}

@ -0,0 +1,161 @@
import React, { useState, useContext, createContext, useReducer, useEffect } from "react";
import Integration from "./Integration";
import * as serverApi from '../../api';
import SpotifyClientCreds from "./spotify/SpotifyClientCreds";
import * as backend from "../backend/integrations";
import { handleNotLoggedIn, NotLoggedInError } from "../backend/request";
import { useAuth } from "../useAuth";
export type IntegrationState = {
id: number,
integration: Integration,
properties: serverApi.CreateIntegrationRequest,
};
export type IntegrationsState = IntegrationState[] | "Loading";
export function isIntegrationState(v: any) : v is IntegrationState {
return 'id' in v && 'integration' in v && 'properties' in v;
}
export interface Integrations {
state: IntegrationsState,
addIntegration: (v: serverApi.CreateIntegrationRequest) => Promise<number>,
deleteIntegration: (id: number) => Promise<void>,
modifyIntegration: (id: number, v: serverApi.CreateIntegrationRequest) => Promise<void>,
updateFromUpstream: () => Promise<void>,
};
export const IntegrationClasses: Record<any, any> = {
[serverApi.IntegrationType.SpotifyClientCredentials]: SpotifyClientCreds,
}
export function makeDefaultIntegrationProperties(type: serverApi.IntegrationType):
serverApi.CreateIntegrationRequest {
switch(type) {
case serverApi.IntegrationType.SpotifyClientCredentials: {
return {
name: "Spotify",
type: type,
details: { clientId: "" },
secretDetails: { clientSecret: "" },
}
}
default: {
throw new Error("Unimplemented default integration.")
}
}
}
export function makeIntegration(p: serverApi.CreateIntegrationRequest, id: number) {
switch(p.type) {
case serverApi.IntegrationType.SpotifyClientCredentials: {
return new SpotifyClientCreds(id);
}
default: {
throw new Error("Unimplemented integration type.")
}
}
}
const integrationsContext = createContext<Integrations>({
state: [],
addIntegration: async () => 0,
modifyIntegration: async () => { },
deleteIntegration: async () => { },
updateFromUpstream: async () => { },
});
export function ProvideIntegrations(props: { children: any }) {
const integrations = useProvideIntegrations();
return <integrationsContext.Provider value={integrations}>{props.children}</integrationsContext.Provider>;
}
export const useIntegrations = () => {
return useContext(integrationsContext);
};
function useProvideIntegrations(): Integrations {
let auth = useAuth();
enum IntegrationsActions {
SetItem = "SetItem",
Set = "Set",
DeleteItem = "DeleteItem",
AddItem = "AddItem",
}
let IntegrationsReducer = (state: IntegrationsState, action: any): IntegrationsState => {
switch (action.type) {
case IntegrationsActions.SetItem: {
if (state !== "Loading") {
return state.map((item: any) => {
return (item.id === action.id) ? action.value : item;
})
}
return state;
}
case IntegrationsActions.Set: {
return action.value;
}
case IntegrationsActions.DeleteItem: {
if (state !== "Loading") {
const newState = [...state];
return newState.filter((item: any) => item.id !== action.id);
}
return state;
}
case IntegrationsActions.AddItem: {
return [...state, action.value];
}
default:
throw new Error("Unimplemented Integrations state update.")
}
}
const [state, dispatch] = useReducer(IntegrationsReducer, [])
let updateFromUpstream = async () => {
backend.getIntegrations()
.then((integrations: serverApi.ListIntegrationsResponse) => {
dispatch({
type: IntegrationsActions.Set,
value: integrations.map((i: any) => {
return {
integration: new (IntegrationClasses[i.type])(i.id),
properties: { ...i },
id: i.id,
}
})
});
})
.catch((e: NotLoggedInError) => handleNotLoggedIn(auth, e));
}
let addIntegration = async (v: serverApi.CreateIntegrationRequest) => {
const id = await backend.createIntegration(v);
await updateFromUpstream();
return id;
}
let deleteIntegration = async (id: number) => {
await backend.deleteIntegration(id);
await updateFromUpstream();
}
let modifyIntegration = async (id: number, v: serverApi.CreateIntegrationRequest) => {
await backend.modifyIntegration(id, v);
await updateFromUpstream();
}
useEffect(() => {
if (auth.user) {
updateFromUpstream()
}
}, [auth]);
return {
state: state,
addIntegration: addIntegration,
modifyIntegration: modifyIntegration,
deleteIntegration: deleteIntegration,
updateFromUpstream: updateFromUpstream,
}
}

@ -1,4 +1,5 @@
import * as serverApi from '../api';
import backendRequest from './backend/request';
export async function saveSongChanges(id: number, change: serverApi.ModifySongRequest) {
const requestOpts = {
@ -8,7 +9,7 @@ export async function saveSongChanges(id: number, change: serverApi.ModifySongRe
};
const endpoint = serverApi.ModifySongEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save song changes: " + response.statusText);
}
@ -22,7 +23,7 @@ export async function saveTagChanges(id: number, change: serverApi.ModifyTagRequ
};
const endpoint = serverApi.ModifyTagEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save tag changes: " + response.statusText);
}
@ -36,7 +37,7 @@ export async function saveArtistChanges(id: number, change: serverApi.ModifyArti
};
const endpoint = serverApi.ModifyArtistEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save artist changes: " + response.statusText);
}
@ -50,7 +51,7 @@ export async function saveAlbumChanges(id: number, change: serverApi.ModifyAlbum
};
const endpoint = serverApi.ModifyAlbumEndpoint.replace(":id", id.toString());
const response = await fetch((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
const response = await backendRequest((process.env.REACT_APP_BACKEND || "") + endpoint, requestOpts)
if(!response.ok) {
throw new Error("Failed to save album changes: " + response.statusText);
}

@ -38,8 +38,37 @@ export const useAuth = () => {
return useContext(authContext);
};
function persistAuth(auth: AuthUser | null) {
let s = window.sessionStorage;
if(auth === null) {
s.removeItem('userId');
s.removeItem('userEmail');
return;
}
s.setItem('userId', auth.id.toString());
s.setItem('userEmail', auth.email);
// TODO icon
}
function loadAuth(): AuthUser | null {
let s = window.sessionStorage;
let id = s.getItem('userId');
let email = s.getItem('userEmail');
if (id && email) {
return {
id: parseInt(id),
email: email,
icon: <PersonIcon />
}
}
return null;
}
function useProvideAuth() {
const [user, setUser] = useState<AuthUser | null>(null);
const [user, setUser] = useState<AuthUser | null>(loadAuth());
// TODO: password maybe shouldn't be encoded into the URL.
const signin = (email: string, password: string) => {
@ -59,6 +88,7 @@ function useProvideAuth() {
icon: <PersonIcon />,
}
setUser(user);
persistAuth(user);
return user;
})();
};
@ -89,6 +119,7 @@ function useProvideAuth() {
throw new Error("Failed to log out.");
}
setUser(null);
persistAuth(null);
})();
};

@ -2,24 +2,19 @@ const bodyParser = require('body-parser');
import * as api from '../client/src/api';
import Knex from 'knex';
import { CreateSongEndpointHandler } from './endpoints/CreateSong';
import { CreateArtistEndpointHandler } from './endpoints/CreateArtist';
import { QueryEndpointHandler } from './endpoints/Query';
import { ArtistDetailsEndpointHandler } from './endpoints/ArtistDetails'
import { SongDetailsEndpointHandler } from './endpoints/SongDetails';
import { ModifyArtistEndpointHandler } from './endpoints/ModifyArtist';
import { ModifySongEndpointHandler } from './endpoints/ModifySong';
import { CreateTagEndpointHandler } from './endpoints/CreateTag';
import { ModifyTagEndpointHandler } from './endpoints/ModifyTag';
import { TagDetailsEndpointHandler } from './endpoints/TagDetails';
import { CreateAlbumEndpointHandler } from './endpoints/CreateAlbum';
import { ModifyAlbumEndpointHandler } from './endpoints/ModifyAlbum';
import { AlbumDetailsEndpointHandler } from './endpoints/AlbumDetails';
import { DeleteTagEndpointHandler } from './endpoints/DeleteTag';
import { MergeTagEndpointHandler } from './endpoints/MergeTag';
import { RegisterUserEndpointHandler } from './endpoints/RegisterUser';
import { Query } from './endpoints/Query';
import { PostArtist, PutArtist, GetArtist } from './endpoints/Artist';
import { PostAlbum, PutAlbum, GetAlbum } from './endpoints/Album';
import { PostSong, PutSong, GetSong } from './endpoints/Song';
import { PostTag, PutTag, GetTag, DeleteTag, MergeTag } from './endpoints/Tag';
import { PostIntegration, PutIntegration, GetIntegration, DeleteIntegration, ListIntegrations } from './endpoints/Integration';
import { RegisterUser } from './endpoints/RegisterUser';
import * as endpointTypes from './endpoints/types';
import { sha512 } from 'js-sha512';
import { createIntegrations } from './integrations/integrations';
// For authentication
var passport = require('passport');
@ -106,28 +101,41 @@ const SetupApp = (app: any, knex: Knex, apiBaseUrl: string) => {
}
}
// Set up integration proxies
app.use('/integrations', checkLogin(), createIntegrations(knex));
// Set up REST API endpoints
app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(CreateSongEndpointHandler));
app.post(apiBaseUrl + api.QueryEndpoint, checkLogin(), _invoke(QueryEndpointHandler));
app.post(apiBaseUrl + api.CreateArtistEndpoint, checkLogin(), _invoke(CreateArtistEndpointHandler));
app.put(apiBaseUrl + api.ModifyArtistEndpoint, checkLogin(), _invoke(ModifyArtistEndpointHandler));
app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(ModifySongEndpointHandler));
app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(SongDetailsEndpointHandler));
app.get(apiBaseUrl + api.ArtistDetailsEndpoint, checkLogin(), _invoke(ArtistDetailsEndpointHandler));
app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(CreateTagEndpointHandler));
app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(ModifyTagEndpointHandler));
app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(TagDetailsEndpointHandler));
app.post(apiBaseUrl + api.CreateAlbumEndpoint, checkLogin(), _invoke(CreateAlbumEndpointHandler));
app.put(apiBaseUrl + api.ModifyAlbumEndpoint, checkLogin(), _invoke(ModifyAlbumEndpointHandler));
app.get(apiBaseUrl + api.AlbumDetailsEndpoint, checkLogin(), _invoke(AlbumDetailsEndpointHandler));
app.delete(apiBaseUrl + api.DeleteTagEndpoint, checkLogin(), _invoke(DeleteTagEndpointHandler));
app.post(apiBaseUrl + api.MergeTagEndpoint, checkLogin(), _invoke(MergeTagEndpointHandler));
app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUserEndpointHandler));
app.post(apiBaseUrl + '/login', passport.authenticate('local'), (req: any, res: any) => {
app.post(apiBaseUrl + api.CreateSongEndpoint, checkLogin(), _invoke(PostSong));
app.put(apiBaseUrl + api.ModifySongEndpoint, checkLogin(), _invoke(PutSong));
app.get(apiBaseUrl + api.SongDetailsEndpoint, checkLogin(), _invoke(GetSong));
app.post(apiBaseUrl + api.QueryEndpoint, checkLogin(), _invoke(Query));
app.post(apiBaseUrl + api.CreateArtistEndpoint, checkLogin(), _invoke(PostArtist));
app.put(apiBaseUrl + api.ModifyArtistEndpoint, checkLogin(), _invoke(PutArtist));
app.get(apiBaseUrl + api.ArtistDetailsEndpoint, checkLogin(), _invoke(GetArtist));
app.post(apiBaseUrl + api.CreateAlbumEndpoint, checkLogin(), _invoke(PostAlbum));
app.put(apiBaseUrl + api.ModifyAlbumEndpoint, checkLogin(), _invoke(PutAlbum));
app.get(apiBaseUrl + api.AlbumDetailsEndpoint, checkLogin(), _invoke(GetAlbum));
app.post(apiBaseUrl + api.CreateTagEndpoint, checkLogin(), _invoke(PostTag));
app.put(apiBaseUrl + api.ModifyTagEndpoint, checkLogin(), _invoke(PutTag));
app.get(apiBaseUrl + api.TagDetailsEndpoint, checkLogin(), _invoke(GetTag));
app.delete(apiBaseUrl + api.DeleteTagEndpoint, checkLogin(), _invoke(DeleteTag));
app.post(apiBaseUrl + api.MergeTagEndpoint, checkLogin(), _invoke(MergeTag));
app.post(apiBaseUrl + api.CreateIntegrationEndpoint, checkLogin(), _invoke(PostIntegration));
app.put(apiBaseUrl + api.ModifyIntegrationEndpoint, checkLogin(), _invoke(PutIntegration));
app.get(apiBaseUrl + api.IntegrationDetailsEndpoint, checkLogin(), _invoke(GetIntegration));
app.delete(apiBaseUrl + api.DeleteIntegrationEndpoint, checkLogin(), _invoke(DeleteIntegration));
app.get(apiBaseUrl + api.ListIntegrationsEndpoint, checkLogin(), _invoke(ListIntegrations));
app.post(apiBaseUrl + api.RegisterUserEndpoint, _invoke(RegisterUser));
app.post(apiBaseUrl + api.LoginEndpoint, passport.authenticate('local'), (req: any, res: any) => {
res.status(200).send({ userId: req.user.id });
});
app.post(apiBaseUrl + '/logout', function (req: any, res: any) {
app.post(apiBaseUrl + api.LogoutEndpoint, function (req: any, res: any) {
req.logout();
res.status(200).send();
});

@ -1,11 +1,165 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
export const GetAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkAlbumDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid GetAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
// Start transfers for songs, tags and artists.
// Also request the album itself.
const tagIdsPromise = knex.select('tagId')
.from('albums_tags')
.where({ 'albumId': req.params.id })
.then((tags: any) => {
return tags.map((tag: any) => tag['tagId'])
});
const songIdsPromise = knex.select('songId')
.from('songs_albums')
.where({ 'albumId': req.params.id })
.then((songs: any) => {
return songs.map((song: any) => song['songId'])
});
const artistIdsPromise = knex.select('artistId')
.from('artists_albums')
.where({ 'albumId': req.params.id })
.then((artists: any) => {
return artists.map((artist: any) => artist['artistId'])
});
const albumPromise = knex.select('name', 'storeLinks')
.from('albums')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((albums: any) => albums[0]);
// Wait for the requests to finish.
const [album, tags, songs, artists] =
await Promise.all([albumPromise, tagIdsPromise, songIdsPromise, artistIdsPromise]);
// Respond to the request.
if (album) {
const response: api.AlbumDetailsResponse = {
name: album['name'],
artistIds: artists,
tagIds: tags,
songIds: songs,
storeLinks: asJson(album['storeLinks']),
};
await res.send(response);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e);
}
}
export const PostAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateAlbumRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PostAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateAlbumRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Post Album ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ?
trx.select('id')
.from('artists')
.where({ 'user': userId })
.whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ?
trx.select('id')
.from('tags')
.where({ 'user': userId })
.whereIn('id', reqObject.tagIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Wait for the requests to finish.
var [artists, tags] = await Promise.all([artistIdsPromise, tagIdsPromise]);;
// Check that we found all artists and tags we need.
if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) ||
(reqObject.tagIds && tags.length !== reqObject.tagIds.length)) {
const e: EndpointError = {
internalMessage: 'Not all albums and/or artists and/or tags exist for CreateAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the album.
const albumId = (await trx('albums')
.insert({
name: reqObject.name,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
user: userId,
})
.returning('id') // Needed for Postgres
)[0];
// Link the artists via the linking table.
if (artists && artists.length) {
await trx('artists_albums').insert(
artists.map((artistId: number) => {
return {
artistId: artistId,
albumId: albumId,
}
})
)
}
// Link the tags via the linking table.
if (tags && tags.length) {
await trx('albums_tags').insert(
tags.map((tagId: number) => {
return {
albumId: albumId,
tagId: tagId,
}
})
)
}
// Respond to the request.
const responseObject: api.CreateSongResponse = {
id: albumId
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
export const PutAlbum: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyAlbumRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid ModifyAlbum request: ' + JSON.stringify(req.body),
internalMessage: 'Invalid PutAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
@ -13,7 +167,7 @@ export const ModifyAlbumEndpointHandler: EndpointHandler = async (req: any, res:
const reqObject: api.ModifyAlbumRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Modify Album ", reqObject);
console.log("User ", userId, ": Put Album ", reqObject);
await knex.transaction(async (trx) => {
try {

@ -1,64 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
export const AlbumDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkAlbumDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid AlbumDetails request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
// Start transfers for songs, tags and artists.
// Also request the album itself.
const tagIdsPromise = knex.select('tagId')
.from('albums_tags')
.where({ 'albumId': req.params.id })
.then((tags: any) => {
return tags.map((tag: any) => tag['tagId'])
});
const songIdsPromise = knex.select('songId')
.from('songs_albums')
.where({ 'albumId': req.params.id })
.then((songs: any) => {
return songs.map((song: any) => song['songId'])
});
const artistIdsPromise = knex.select('artistId')
.from('artists_albums')
.where({ 'albumId': req.params.id })
.then((artists: any) => {
return artists.map((artist: any) => artist['artistId'])
});
const albumPromise = knex.select('name', 'storeLinks')
.from('albums')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((albums: any) => albums[0]);
// Wait for the requests to finish.
const [album, tags, songs, artists] =
await Promise.all([albumPromise, tagIdsPromise, songIdsPromise, artistIdsPromise]);
// Respond to the request.
if (album) {
const response: api.AlbumDetailsResponse = {
name: album['name'],
artistIds: artists,
tagIds: tags,
songIds: songs,
storeLinks: asJson(album['storeLinks']),
};
await res.send(response);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e);
}
}

@ -1,11 +1,117 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
export const ModifyArtistEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
export const GetArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkArtistDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid GetArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
const tagIds = Array.from(new Set((await knex.select('tagId')
.from('artists_tags')
.where({ 'artistId': req.params.id })
).map((tag: any) => tag['tagId'])));
const results = await knex.select(['id', 'name', 'storeLinks'])
.from('artists')
.where({ 'user': userId })
.where({ 'id': req.params.id });
if (results[0]) {
const response: api.ArtistDetailsResponse = {
name: results[0].name,
tagIds: tagIds,
storeLinks: asJson(results[0].storeLinks),
}
await res.send(response);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e)
}
}
export const PostArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateArtistRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PostArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateArtistRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Create artist ", reqObject)
await knex.transaction(async (trx) => {
try {
// Retrieve tag instances to link the artist to.
const tags: number[] = reqObject.tagIds ?
Array.from(new Set(
(await trx.select('id').from('tags')
.where({ 'user': userId })
.whereIn('id', reqObject.tagIds))
.map((tag: any) => tag['id'])
))
: [];
if (reqObject.tagIds && tags && tags.length !== reqObject.tagIds.length) {
const e: EndpointError = {
internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the artist.
const artistId = (await trx('artists')
.insert({
name: reqObject.name,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
user: userId,
})
.returning('id') // Needed for Postgres
)[0];
// Link the tags via the linking table.
if (tags && tags.length) {
await trx('artists_tags').insert(
tags.map((tagId: number) => {
return {
artistId: artistId,
tagId: tagId,
}
})
)
}
const responseObject: api.CreateSongResponse = {
id: artistId
};
await res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
});
}
export const PutArtist: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyArtistRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid ModifyArtist request: ' + JSON.stringify(req.body),
internalMessage: 'Invalid PutArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
@ -13,7 +119,7 @@ export const ModifyArtistEndpointHandler: EndpointHandler = async (req: any, res
const reqObject: api.ModifyArtistRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Modify Artist ", reqObject);
console.log("User ", userId, ": Put Artist ", reqObject);
await knex.transaction(async (trx) => {
try {

@ -1,41 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
export const ArtistDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkArtistDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid ArtistDetails request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
const tagIds = Array.from(new Set((await knex.select('tagId')
.from('artists_tags')
.where({ 'artistId': req.params.id })
).map((tag: any) => tag['tagId'])));
const results = await knex.select(['id', 'name', 'storeLinks'])
.from('artists')
.where({ 'user': userId })
.where({ 'id': req.params.id });
if (results[0]) {
const response: api.ArtistDetailsResponse = {
name: results[0].name,
tagIds: tagIds,
storeLinks: asJson(results[0].storeLinks),
}
await res.send(response);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e)
}
}

@ -1,96 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const CreateAlbumEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateAlbumRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid CreateAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateAlbumRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Create Album ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ?
trx.select('id')
.from('artists')
.where({ 'user': userId })
.whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ?
trx.select('id')
.from('tags')
.where({ 'user': userId })
.whereIn('id', reqObject.tagIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Wait for the requests to finish.
var [artists, tags] = await Promise.all([artistIdsPromise, tagIdsPromise]);;
// Check that we found all artists and tags we need.
if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) ||
(reqObject.tagIds && tags.length !== reqObject.tagIds.length)) {
const e: EndpointError = {
internalMessage: 'Not all albums and/or artists and/or tags exist for CreateAlbum request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the album.
const albumId = (await trx('albums')
.insert({
name: reqObject.name,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
user: userId,
})
.returning('id') // Needed for Postgres
)[0];
// Link the artists via the linking table.
if (artists && artists.length) {
await trx('artists_albums').insert(
artists.map((artistId: number) => {
return {
artistId: artistId,
albumId: albumId,
}
})
)
}
// Link the tags via the linking table.
if (tags && tags.length) {
await trx('albums_tags').insert(
tags.map((tagId: number) => {
return {
albumId: albumId,
tagId: tagId,
}
})
)
}
// Respond to the request.
const responseObject: api.CreateSongResponse = {
id: albumId
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -1,70 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const CreateArtistEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateArtistRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid CreateArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateArtistRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Create artist ", reqObject)
await knex.transaction(async (trx) => {
try {
// Retrieve tag instances to link the artist to.
const tags: number[] = reqObject.tagIds ?
Array.from(new Set(
(await trx.select('id').from('tags')
.where({ 'user': userId })
.whereIn('id', reqObject.tagIds))
.map((tag: any) => tag['id'])
))
: [];
if (reqObject.tagIds && tags && tags.length !== reqObject.tagIds.length) {
const e: EndpointError = {
internalMessage: 'Not all tags exist for CreateArtist request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the artist.
const artistId = (await trx('artists')
.insert({
name: reqObject.name,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
user: userId,
})
.returning('id') // Needed for Postgres
)[0];
// Link the tags via the linking table.
if (tags && tags.length) {
await trx('artists_tags').insert(
tags.map((tagId: number) => {
return {
artistId: artistId,
tagId: tagId,
}
})
)
}
const responseObject: api.CreateSongResponse = {
id: artistId
};
await res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
});
}

@ -1,118 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const CreateSongEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateSongRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid CreateSong request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateSongRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Create Song ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ?
trx.select('id')
.from('artists')
.where({ 'user': userId })
.whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ?
trx.select('id')
.from('tags')
.where({ 'user': userId })
.whereIn('id', reqObject.tagIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Start retrieving albums.
const albumIdsPromise = reqObject.albumIds ?
trx.select('id')
.from('albums')
.where({ 'user': userId })
.whereIn('id', reqObject.albumIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Wait for the requests to finish.
var [artists, tags, albums] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdsPromise]);;
// Check that we found all objects we need.
if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) ||
(reqObject.tagIds && tags.length !== reqObject.tagIds.length) ||
(reqObject.albumIds && albums.length !== reqObject.albumIds.length)) {
const e: EndpointError = {
internalMessage: 'Not all albums and/or artists and/or tags exist for CreateSong request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the song.
const songId = (await trx('songs')
.insert({
title: reqObject.title,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
user: userId,
})
.returning('id') // Needed for Postgres
)[0];
// Link the artists via the linking table.
if (artists && artists.length) {
await Promise.all(
artists.map((artistId: number) => {
return trx('songs_artists').insert({
artistId: artistId,
songId: songId,
})
})
)
}
// Link the tags via the linking table.
if (tags && tags.length) {
await Promise.all(
tags.map((tagId: number) => {
return trx('songs_tags').insert({
songId: songId,
tagId: tagId,
})
})
)
}
// Link the albums via the linking table.
if (albums && albums.length) {
await Promise.all(
albums.map((albumId: number) => {
return trx('songs_albums').insert({
songId: songId,
albumId: albumId,
})
})
)
}
// Respond to the request.
const responseObject: api.CreateSongResponse = {
id: songId
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -1,62 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const CreateTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid CreateTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Create Tag ", reqObject);
await knex.transaction(async (trx) => {
try {
// If applicable, retrieve the parent tag.
const maybeParent: number | undefined =
reqObject.parentId ?
(await trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'id': reqObject.parentId }))[0]['id'] :
undefined;
// Check if the parent was found, if applicable.
if (reqObject.parentId && maybeParent !== reqObject.parentId) {
const e: EndpointError = {
internalMessage: 'Could not find parent tag for CreateTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the new tag.
var tag: any = {
name: reqObject.name,
user: userId,
};
if (maybeParent) {
tag['parentId'] = maybeParent;
}
const tagId = (await trx('tags')
.insert(tag)
.returning('id') // Needed for Postgres
)[0];
// Respond to the request.
const responseObject: api.CreateTagResponse = {
id: tagId
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -1,78 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
async function getChildrenRecursive(id: number, userId: number, trx: any) {
const directChildren = (await trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'parentId': id })).map((r: any) => r.id);
const indirectChildrenPromises = directChildren.map(
(child: number) => getChildrenRecursive(child, userId, trx)
);
const indirectChildrenNested = await Promise.all(indirectChildrenPromises);
const indirectChildren = indirectChildrenNested.flat();
return [
...directChildren,
...indirectChildren,
]
}
export const DeleteTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkDeleteTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Delete Tag ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving any child tags.
const childTagsPromise =
getChildrenRecursive(req.params.id, userId, trx);
// Start retrieving the tag itself.
const tagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [tag, children] = await Promise.all([tagPromise, childTagsPromise]);
// Merge all IDs.
const toDelete = [ tag, ...children ];
// Check that we found all objects we need.
if (!tag) {
const e: EndpointError = {
internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Delete the tag and its children.
await trx('tags')
.where({ 'user': userId })
.whereIn('id', toDelete)
.del();
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -0,0 +1,206 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
export const PostIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateIntegrationRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PostIntegration request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateIntegrationRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Post Integration ", reqObject);
await knex.transaction(async (trx) => {
try {
// Create the new integration.
var integration: any = {
name: reqObject.name,
user: userId,
type: reqObject.type,
details: JSON.stringify(reqObject.details),
secretDetails: JSON.stringify(reqObject.secretDetails),
}
const integrationId = (await trx('integrations')
.insert(integration)
.returning('id') // Needed for Postgres
)[0];
// Respond to the request.
const responseObject: api.CreateIntegrationResponse = {
id: integrationId
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
export const GetIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkIntegrationDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid GetIntegration request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
const integration = (await knex.select(['id', 'name', 'type', 'details'])
.from('integrations')
.where({ 'user': userId, 'id': req.params.id }))[0];
if (integration) {
const response: api.IntegrationDetailsResponse = {
name: integration.name,
type: integration.type,
details: asJson(integration.details),
}
await res.send(response);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e)
}
}
export const ListIntegrations: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkIntegrationDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid ListIntegrations request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
console.log("List integrations");
try {
const integrations: api.ListIntegrationsResponse = (
await knex.select(['id', 'name', 'type', 'details'])
.from('integrations')
.where({ user: userId })
).map((object: any) => {
return {
id: object.id,
name: object.name,
type: object.type,
details: asJson(object.details),
}
})
console.log("Found integrations:", integrations);
await res.send(integrations);
} catch (e) {
catchUnhandledErrors(e)
}
}
export const DeleteIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkDeleteIntegrationRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid DeleteIntegration request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteIntegrationRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Delete Integration ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving the integration itself.
const integrationId = await trx.select('id')
.from('integrations')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Check that we found all objects we need.
if (!integrationId) {
const e: EndpointError = {
internalMessage: 'Integration does not exist for DeleteIntegration request: ' + JSON.stringify(req.body),
httpStatus: 404
};
throw e;
}
// Delete the integration.
await trx('integrations')
.where({ 'user': userId, 'id': integrationId })
.del();
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
export const PutIntegration: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyIntegrationRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PutIntegration request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.ModifyIntegrationRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Put Integration ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving the integration.
const integrationId = await trx.select('id')
.from('integrations')
.where({ 'user': userId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Check that we found all objects we need.
if (!integrationId) {
const e: EndpointError = {
internalMessage: 'Integration does not exist for ModifyIntegration request: ' + JSON.stringify(req.body),
httpStatus: 404
};
throw e;
}
// Modify the integration.
var update: any = {};
if ("name" in reqObject) { update["name"] = reqObject.name; }
if ("details" in reqObject) { update["details"] = JSON.stringify(reqObject.details); }
if ("type" in reqObject) { update["type"] = reqObject.type; }
if ("secretDetails" in reqObject) { update["secretDetails"] = JSON.stringify(reqObject.details); }
await trx('integrations')
.where({ 'user': userId, 'id': req.params.id })
.update(update)
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -1,78 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const MergeTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkMergeTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Merge Tag ", reqObject);
const fromId = req.params.id;
const toId = req.params.toId;
await knex.transaction(async (trx) => {
try {
// Start retrieving the "from" tag.
const fromTagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: fromId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Start retrieving the "to" tag.
const toTagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: toId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]);
// Check that we found all objects we need.
if (!fromTag || !toTag) {
const e: EndpointError = {
internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Assign new tag ID to any objects referencing the to-be-merged tag.
const cPromise = trx('tags')
.where({ 'user': userId })
.where({ 'parentId': fromId })
.update({ 'parentId': toId });
const sPromise = trx('songs_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
const arPromise = trx('artists_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
const alPromise = trx('albums_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
await Promise.all([sPromise, arPromise, alPromise, cPromise]);
// Delete the original tag.
await trx('tags')
.where({ 'user': userId })
.where({ 'id': fromId })
.del();
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -1,191 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const ModifySongEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifySongRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid ModifySong request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.ModifySongRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Modify Song ", reqObject);
await knex.transaction(async (trx) => {
try {
// Retrieve the song to be modified itself.
const songPromise = trx.select('id')
.from('songs')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ?
trx.select('artistId')
.from('songs_artists')
.whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['artistId'])) :
(async () => { return undefined })();
// Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ?
trx.select('id')
.from('songs_tags')
.whereIn('id', reqObject.tagIds)
.then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return undefined })();
// Start retrieving albums.
const albumIdsPromise = reqObject.albumIds ?
trx.select('id')
.from('songs_albums')
.whereIn('id', reqObject.albumIds)
.then((as: any) => as.map((a: any) => a['albumId'])) :
(async () => { return undefined })();
// Wait for the requests to finish.
var [song, artists, tags, albums] =
await Promise.all([songPromise, artistIdsPromise, tagIdsPromise, albumIdsPromise]);;
// Check that we found all objects we need.
if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) ||
(reqObject.tagIds && tags.length !== reqObject.tagIds.length) ||
(reqObject.albumIds && albums.length !== reqObject.albumIds.length) ||
!song) {
const e: EndpointError = {
internalMessage: 'Not all albums and/or artists and/or tags exist for ModifySong request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Modify the song.
var update: any = {};
if ("title" in reqObject) { update["title"] = reqObject.title; }
if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); }
const modifySongPromise = trx('songs')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.update(update)
// Remove unlinked artists.
// TODO: test this!
const removeUnlinkedArtists = artists ? trx('songs_artists')
.where({ 'songId': req.params.id })
.whereNotIn('artistId', reqObject.artistIds || [])
.delete() : undefined;
// Remove unlinked tags.
// TODO: test this!
const removeUnlinkedTags = tags ? trx('songs_tags')
.where({ 'songId': req.params.id })
.whereNotIn('tagId', reqObject.tagIds || [])
.delete() : undefined;
// Remove unlinked albums.
// TODO: test this!
const removeUnlinkedAlbums = albums ? trx('songs_albums')
.where({ 'songId': req.params.id })
.whereNotIn('albumId', reqObject.albumIds || [])
.delete() : undefined;
// Link new artists.
// TODO: test this!
const addArtists = artists ? trx('songs_artists')
.where({ 'songId': req.params.id })
.then((as: any) => as.map((a: any) => a['artistId']))
.then((doneArtistIds: number[]) => {
// Get the set of artists that are not yet linked
const toLink = artists.filter((id: number) => {
return !doneArtistIds.includes(id);
});
const insertObjects = toLink.map((artistId: number) => {
return {
artistId: artistId,
songId: req.params.id,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('songs_artists').insert(obj)
)
);
}) : undefined;
// Link new tags.
// TODO: test this!
const addTags = tags ? trx('songs_tags')
.where({ 'songId': req.params.id })
.then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => {
// Get the set of tags that are not yet linked
const toLink = tags.filter((id: number) => {
return !doneTagIds.includes(id);
});
const insertObjects = toLink.map((tagId: number) => {
return {
tagId: tagId,
songId: req.params.id,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('songs_tags').insert(obj)
)
);
}) : undefined;
// Link new albums.
// TODO: test this!
const addAlbums = albums ? trx('songs_albums')
.where({ 'albumId': req.params.id })
.then((as: any) => as.map((a: any) => a['albumId']))
.then((doneAlbumIds: number[]) => {
// Get the set of albums that are not yet linked
const toLink = albums.filter((id: number) => {
return !doneAlbumIds.includes(id);
});
const insertObjects = toLink.map((albumId: number) => {
return {
albumId: albumId,
songId: req.params.id,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('songs_albums').insert(obj)
)
);
}) : undefined;
// Wait for all operations to finish.
await Promise.all([
modifySongPromise,
removeUnlinkedArtists,
removeUnlinkedTags,
removeUnlinkedAlbums,
addArtists,
addTags,
addAlbums,
]);
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -1,66 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const ModifyTagEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid ModifyTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.ModifyTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Modify Tag ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving the parent tag.
const parentTagPromise = reqObject.parentId ?
trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'id': reqObject.parentId })
.then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return [] })();
// Start retrieving the tag itself.
const tagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [tag, parent] = await Promise.all([tagPromise, parentTagPromise]);;
// Check that we found all objects we need.
if ((reqObject.parentId && !parent) ||
!tag) {
const e: EndpointError = {
internalMessage: 'Tag or parent does not exist for ModifyTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Modify the tag.
await trx('tags')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.update({
name: reqObject.name,
parentId: reqObject.parentId || null,
})
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -258,7 +258,7 @@ async function getFullTag(knex: Knex, userId: number, tag: any): Promise<any> {
return await resolveTag(tag);
}
export const QueryEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
export const Query: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkQueryRequest(req.body)) {
const e: EndpointError = {
internalMessage: 'Invalid Query request: ' + JSON.stringify(req.body),

@ -4,7 +4,7 @@ import Knex from 'knex';
import { sha512 } from 'js-sha512';
export const RegisterUserEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
export const RegisterUser: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkRegisterUserRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid RegisterUser request: ' + JSON.stringify(req.body),

@ -0,0 +1,372 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
export const PostSong: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateSongRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PostSong request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateSongRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Post Song ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ?
trx.select('id')
.from('artists')
.where({ 'user': userId })
.whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ?
trx.select('id')
.from('tags')
.where({ 'user': userId })
.whereIn('id', reqObject.tagIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Start retrieving albums.
const albumIdsPromise = reqObject.albumIds ?
trx.select('id')
.from('albums')
.where({ 'user': userId })
.whereIn('id', reqObject.albumIds)
.then((as: any) => as.map((a: any) => a['id'])) :
(async () => { return [] })();
// Wait for the requests to finish.
var [artists, tags, albums] = await Promise.all([artistIdsPromise, tagIdsPromise, albumIdsPromise]);;
// Check that we found all objects we need.
if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) ||
(reqObject.tagIds && tags.length !== reqObject.tagIds.length) ||
(reqObject.albumIds && albums.length !== reqObject.albumIds.length)) {
const e: EndpointError = {
internalMessage: 'Not all albums and/or artists and/or tags exist for CreateSong request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the song.
const songId = (await trx('songs')
.insert({
title: reqObject.title,
storeLinks: JSON.stringify(reqObject.storeLinks || []),
user: userId,
})
.returning('id') // Needed for Postgres
)[0];
// Link the artists via the linking table.
if (artists && artists.length) {
await Promise.all(
artists.map((artistId: number) => {
return trx('songs_artists').insert({
artistId: artistId,
songId: songId,
})
})
)
}
// Link the tags via the linking table.
if (tags && tags.length) {
await Promise.all(
tags.map((tagId: number) => {
return trx('songs_tags').insert({
songId: songId,
tagId: tagId,
})
})
)
}
// Link the albums via the linking table.
if (albums && albums.length) {
await Promise.all(
albums.map((albumId: number) => {
return trx('songs_albums').insert({
songId: songId,
albumId: albumId,
})
})
)
}
// Respond to the request.
const responseObject: api.CreateSongResponse = {
id: songId
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
export const GetSong: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkSongDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid GetSong request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
const tagIdsPromise: Promise<number[]> = knex.select('tagId')
.from('songs_tags')
.where({ 'songId': req.params.id })
.then((ts: any) => {
return Array.from(new Set(
ts.map((tag: any) => tag['tagId'])
));
})
const albumIdsPromise: Promise<number[]> = knex.select('albumId')
.from('songs_albums')
.where({ 'songId': req.params.id })
.then((as: any) => {
return Array.from(new Set(
as.map((album: any) => album['albumId'])
));
})
const artistIdsPromise: Promise<number[]> = knex.select('artistId')
.from('songs_artists')
.where({ 'songId': req.params.id })
.then((as: any) => {
return Array.from(new Set(
as.map((artist: any) => artist['artistId'])
));
})
const songPromise = await knex.select(['id', 'title', 'storeLinks'])
.from('songs')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.then((ss: any) => ss[0])
const [tags, albums, artists, song] =
await Promise.all([tagIdsPromise, albumIdsPromise, artistIdsPromise, songPromise]);
if (song) {
const response: api.SongDetailsResponse = {
title: song.title,
tagIds: tags,
artistIds: artists,
albumIds: albums,
storeLinks: asJson(song.storeLinks),
}
await res.send(response);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e)
}
}
export const PutSong: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifySongRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PutSong request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.ModifySongRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Put Song ", reqObject);
await knex.transaction(async (trx) => {
try {
// Retrieve the song to be modified itself.
const songPromise = trx.select('id')
.from('songs')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Start retrieving artists.
const artistIdsPromise = reqObject.artistIds ?
trx.select('artistId')
.from('songs_artists')
.whereIn('id', reqObject.artistIds)
.then((as: any) => as.map((a: any) => a['artistId'])) :
(async () => { return undefined })();
// Start retrieving tags.
const tagIdsPromise = reqObject.tagIds ?
trx.select('id')
.from('songs_tags')
.whereIn('id', reqObject.tagIds)
.then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return undefined })();
// Start retrieving albums.
const albumIdsPromise = reqObject.albumIds ?
trx.select('id')
.from('songs_albums')
.whereIn('id', reqObject.albumIds)
.then((as: any) => as.map((a: any) => a['albumId'])) :
(async () => { return undefined })();
// Wait for the requests to finish.
var [song, artists, tags, albums] =
await Promise.all([songPromise, artistIdsPromise, tagIdsPromise, albumIdsPromise]);;
// Check that we found all objects we need.
if ((reqObject.artistIds && artists.length !== reqObject.artistIds.length) ||
(reqObject.tagIds && tags.length !== reqObject.tagIds.length) ||
(reqObject.albumIds && albums.length !== reqObject.albumIds.length) ||
!song) {
const e: EndpointError = {
internalMessage: 'Not all albums and/or artists and/or tags exist for ModifySong request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Modify the song.
var update: any = {};
if ("title" in reqObject) { update["title"] = reqObject.title; }
if ("storeLinks" in reqObject) { update["storeLinks"] = JSON.stringify(reqObject.storeLinks || []); }
const modifySongPromise = trx('songs')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.update(update)
// Remove unlinked artists.
// TODO: test this!
const removeUnlinkedArtists = artists ? trx('songs_artists')
.where({ 'songId': req.params.id })
.whereNotIn('artistId', reqObject.artistIds || [])
.delete() : undefined;
// Remove unlinked tags.
// TODO: test this!
const removeUnlinkedTags = tags ? trx('songs_tags')
.where({ 'songId': req.params.id })
.whereNotIn('tagId', reqObject.tagIds || [])
.delete() : undefined;
// Remove unlinked albums.
// TODO: test this!
const removeUnlinkedAlbums = albums ? trx('songs_albums')
.where({ 'songId': req.params.id })
.whereNotIn('albumId', reqObject.albumIds || [])
.delete() : undefined;
// Link new artists.
// TODO: test this!
const addArtists = artists ? trx('songs_artists')
.where({ 'songId': req.params.id })
.then((as: any) => as.map((a: any) => a['artistId']))
.then((doneArtistIds: number[]) => {
// Get the set of artists that are not yet linked
const toLink = artists.filter((id: number) => {
return !doneArtistIds.includes(id);
});
const insertObjects = toLink.map((artistId: number) => {
return {
artistId: artistId,
songId: req.params.id,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('songs_artists').insert(obj)
)
);
}) : undefined;
// Link new tags.
// TODO: test this!
const addTags = tags ? trx('songs_tags')
.where({ 'songId': req.params.id })
.then((ts: any) => ts.map((t: any) => t['tagId']))
.then((doneTagIds: number[]) => {
// Get the set of tags that are not yet linked
const toLink = tags.filter((id: number) => {
return !doneTagIds.includes(id);
});
const insertObjects = toLink.map((tagId: number) => {
return {
tagId: tagId,
songId: req.params.id,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('songs_tags').insert(obj)
)
);
}) : undefined;
// Link new albums.
// TODO: test this!
const addAlbums = albums ? trx('songs_albums')
.where({ 'albumId': req.params.id })
.then((as: any) => as.map((a: any) => a['albumId']))
.then((doneAlbumIds: number[]) => {
// Get the set of albums that are not yet linked
const toLink = albums.filter((id: number) => {
return !doneAlbumIds.includes(id);
});
const insertObjects = toLink.map((albumId: number) => {
return {
albumId: albumId,
songId: req.params.id,
}
})
// Link them
return Promise.all(
insertObjects.map((obj: any) =>
trx('songs_albums').insert(obj)
)
);
}) : undefined;
// Wait for all operations to finish.
await Promise.all([
modifySongPromise,
removeUnlinkedArtists,
removeUnlinkedTags,
removeUnlinkedAlbums,
addArtists,
addTags,
addAlbums,
]);
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -1,68 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
import asJson from '../lib/asJson';
export const SongDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkSongDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid SongDetails request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
const tagIdsPromise: Promise<number[]> = knex.select('tagId')
.from('songs_tags')
.where({ 'songId': req.params.id })
.then((ts: any) => {
return Array.from(new Set(
ts.map((tag: any) => tag['tagId'])
));
})
const albumIdsPromise: Promise<number[]> = knex.select('albumId')
.from('songs_albums')
.where({ 'songId': req.params.id })
.then((as: any) => {
return Array.from(new Set(
as.map((album: any) => album['albumId'])
));
})
const artistIdsPromise: Promise<number[]> = knex.select('artistId')
.from('songs_artists')
.where({ 'songId': req.params.id })
.then((as: any) => {
return Array.from(new Set(
as.map((artist: any) => artist['artistId'])
));
})
const songPromise = await knex.select(['id', 'title', 'storeLinks'])
.from('songs')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.then((ss: any) => ss[0])
const [tags, albums, artists, song] =
await Promise.all([tagIdsPromise, albumIdsPromise, artistIdsPromise, songPromise]);
if (song) {
const response: api.SongDetailsResponse = {
title: song.title,
tagIds: tags,
artistIds: artists,
albumIds: albums,
storeLinks: asJson(song.storeLinks),
}
await res.send(response);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e)
}
}

@ -0,0 +1,306 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const PostTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkCreateTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PostTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.CreateTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Post Tag ", reqObject);
await knex.transaction(async (trx) => {
try {
// If applicable, retrieve the parent tag.
const maybeParent: number | undefined =
reqObject.parentId ?
(await trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'id': reqObject.parentId }))[0]['id'] :
undefined;
// Check if the parent was found, if applicable.
if (reqObject.parentId && maybeParent !== reqObject.parentId) {
const e: EndpointError = {
internalMessage: 'Could not find parent tag for CreateTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Create the new tag.
var tag: any = {
name: reqObject.name,
user: userId,
};
if (maybeParent) {
tag['parentId'] = maybeParent;
}
const tagId = (await trx('tags')
.insert(tag)
.returning('id') // Needed for Postgres
)[0];
// Respond to the request.
const responseObject: api.CreateTagResponse = {
id: tagId
};
res.status(200).send(responseObject);
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
async function getChildrenRecursive(id: number, userId: number, trx: any) {
const directChildren = (await trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'parentId': id })).map((r: any) => r.id);
const indirectChildrenPromises = directChildren.map(
(child: number) => getChildrenRecursive(child, userId, trx)
);
const indirectChildrenNested = await Promise.all(indirectChildrenPromises);
const indirectChildren = indirectChildrenNested.flat();
return [
...directChildren,
...indirectChildren,
]
}
export const DeleteTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkDeleteTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid DeleteTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Delete Tag ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving any child tags.
const childTagsPromise =
getChildrenRecursive(req.params.id, userId, trx);
// Start retrieving the tag itself.
const tagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [tag, children] = await Promise.all([tagPromise, childTagsPromise]);
// Merge all IDs.
const toDelete = [ tag, ...children ];
// Check that we found all objects we need.
if (!tag) {
const e: EndpointError = {
internalMessage: 'Tag or parent does not exist for DeleteTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Delete the tag and its children.
await trx('tags')
.where({ 'user': userId })
.whereIn('id', toDelete)
.del();
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
export const GetTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkTagDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid GetTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
const results = await knex.select(['id', 'name', 'parentId'])
.from('tags')
.where({ 'user': userId })
.where({ 'id': req.params.id });
if (results[0]) {
const response: api.TagDetailsResponse = {
name: results[0].name,
parentId: results[0].parentId || undefined,
}
await res.send(response);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e)
}
}
export const PutTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkModifyTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid PutTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.ModifyTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Put Tag ", reqObject);
await knex.transaction(async (trx) => {
try {
// Start retrieving the parent tag.
const parentTagPromise = reqObject.parentId ?
trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ 'id': reqObject.parentId })
.then((ts: any) => ts.map((t: any) => t['tagId'])) :
(async () => { return [] })();
// Start retrieving the tag itself.
const tagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: req.params.id })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [tag, parent] = await Promise.all([tagPromise, parentTagPromise]);;
// Check that we found all objects we need.
if ((reqObject.parentId && !parent) ||
!tag) {
const e: EndpointError = {
internalMessage: 'Tag or parent does not exist for ModifyTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Modify the tag.
await trx('tags')
.where({ 'user': userId })
.where({ 'id': req.params.id })
.update({
name: reqObject.name,
parentId: reqObject.parentId || null,
})
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}
export const MergeTag: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkMergeTagRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid MergeTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const reqObject: api.DeleteTagRequest = req.body;
const { id: userId } = req.user;
console.log("User ", userId, ": Merge Tag ", reqObject);
const fromId = req.params.id;
const toId = req.params.toId;
await knex.transaction(async (trx) => {
try {
// Start retrieving the "from" tag.
const fromTagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: fromId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Start retrieving the "to" tag.
const toTagPromise = trx.select('id')
.from('tags')
.where({ 'user': userId })
.where({ id: toId })
.then((r: any) => (r && r[0]) ? r[0]['id'] : undefined)
// Wait for the requests to finish.
var [fromTag, toTag] = await Promise.all([fromTagPromise, toTagPromise]);
// Check that we found all objects we need.
if (!fromTag || !toTag) {
const e: EndpointError = {
internalMessage: 'Source or target tag does not exist for MergeTag request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
// Assign new tag ID to any objects referencing the to-be-merged tag.
const cPromise = trx('tags')
.where({ 'user': userId })
.where({ 'parentId': fromId })
.update({ 'parentId': toId });
const sPromise = trx('songs_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
const arPromise = trx('artists_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
const alPromise = trx('albums_tags')
.where({ 'tagId': fromId })
.update({ 'tagId': toId });
await Promise.all([sPromise, arPromise, alPromise, cPromise]);
// Delete the original tag.
await trx('tags')
.where({ 'user': userId })
.where({ 'id': fromId })
.del();
// Respond to the request.
res.status(200).send();
} catch (e) {
catchUnhandledErrors(e);
trx.rollback();
}
})
}

@ -1,34 +0,0 @@
import * as api from '../../client/src/api';
import { EndpointError, EndpointHandler, catchUnhandledErrors } from './types';
import Knex from 'knex';
export const TagDetailsEndpointHandler: EndpointHandler = async (req: any, res: any, knex: Knex) => {
if (!api.checkTagDetailsRequest(req)) {
const e: EndpointError = {
internalMessage: 'Invalid TagDetails request: ' + JSON.stringify(req.body),
httpStatus: 400
};
throw e;
}
const { id: userId } = req.user;
try {
const results = await knex.select(['id', 'name', 'parentId'])
.from('tags')
.where({ 'user': userId })
.where({ 'id': req.params.id });
if (results[0]) {
const response: api.TagDetailsResponse = {
name: results[0].name,
parentId: results[0].parentId || undefined,
}
await res.send(response);
} else {
await res.status(404).send({});
}
} catch (e) {
catchUnhandledErrors(e)
}
}

@ -0,0 +1,111 @@
import Knex from "knex";
import { IntegrationType } from "../../client/src/api";
const { createProxyMiddleware } = require('http-proxy-middleware');
let axios = require('axios')
let qs = require('querystring')
async function getSpotifyCCAuthToken(clientId: string, clientSecret: string) {
console.log("Details: ", clientId, clientSecret);
let buf = Buffer.from(clientId + ':' + clientSecret)
let encoded = buf.toString('base64');
let response = await axios.post(
'https://accounts.spotify.com/api/token',
qs.stringify({ 'grant_type': 'client_credentials' }),
{
'headers': {
'Authorization': 'Basic ' + encoded,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
if (response.status != 200) {
throw new Error("Unable to get a Spotify auth token.")
}
return (await response).data.access_token;
}
export function createIntegrations(knex: Knex) {
// This will enable the app to redirect requests like:
// /integrations/5/v1/search?q=query
// To the external API represented by integration 5, e.g. for spotify:
// https://api.spotify.com/v1/search?q=query
// Requests need to already have a .user.id set.
let proxySpotifyCC = createProxyMiddleware({
target: 'https://api.spotify.com/',
changeOrigin: true,
logLevel: 'debug',
pathRewrite: (path: string, req: any) => {
// Remove e.g. "/integrations/5"
console.log("Rewrite URL:", path);
return path.replace(/^\/integrations\/[0-9]+/, '');
}
});
// In the first layer, retrieve integration details and save details
// in the request.
return async (req: any, res: any, next: any) => {
// Determine the integration to use.
req._integrationId = parseInt(req.url.match(/^\/([0-9]+)/)[1]);
console.log("URL:", req.url, 'match:', req._integrationId)
if (!req._integrationId) {
res.status(400).send({ reason: "An integration ID should be provided in the URL." });
return;
}
req._integration = (await knex.select(['id', 'name', 'type', 'details', 'secretDetails'])
.from('integrations')
.where({ 'user': req.user.id, 'id': req._integrationId }))[0];
if (!req._integration) {
res.status(404).send();
return;
}
req._integration.details = JSON.parse(req._integration.details);
req._integration.secretDetails = JSON.parse(req._integration.secretDetails);
switch (req._integration.type) {
case IntegrationType.SpotifyClientCredentials: {
console.log("Integration: ", req._integration)
// FIXME: persist the token
req._access_token = await getSpotifyCCAuthToken(
req._integration.details.clientId,
req._integration.secretDetails.clientSecret,
)
if (!req._access_token) {
res.status(500).send({ reason: "Unable to get Spotify auth token." })
}
req.headers["Authorization"] = "Bearer " + req._access_token;
return proxySpotifyCC(req, res, next);
}
default: {
res.status(500).send({ reason: "Unsupported integration type " + req._integration.type })
}
}
};
// // First add a layer which creates a token and saves it in the request.
// app.use((req: any, res: any, next: any) => {
// updateToken('c3e5e605e7814cdf94cd86eeba6f4c4f', '5d870c84a3c34aa3a4cf803aa95cb96a')
// .then(() => {
// req._access_token = authToken;
// next();
// })
// })
// app.use(
// '/spotifycc',
// createProxyMiddleware({
// target: 'https://api.spotify.com/',
// changeOrigin: true,
// onProxyReq: onProxyReq,
// logLevel: 'debug',
// pathRewrite: { '^/spotifycc': '' },
// })
// )
}

@ -0,0 +1,24 @@
import * as Knex from "knex";
export async function up(knex: Knex): Promise<void> {
// Integrations table.
await knex.schema.createTable(
'integrations',
(table: any) => {
table.increments('id');
table.integer('user').unsigned().notNullable().defaultTo(1);
table.string('name').notNullable(); // Uniquely identifies this integration configuration for the user.
table.string('type').notNullable(); // Enumerates different supported integration types (e.g. Spotify)
table.json('details'); // Stores anything that might be needed for the integration to work.
table.json('secretDetails'); // Stores anything that might be needed for the integration to work and which
// should never leave the server.
}
)
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('integrations');
}

@ -24,6 +24,14 @@
"xml2js": "^0.4.19"
},
"dependencies": {
"axios": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
"integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
"requires": {
"follow-redirects": "1.5.10"
}
},
"uuid": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
@ -69,6 +77,14 @@
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz",
"integrity": "sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw=="
},
"@types/http-proxy": {
"version": "1.17.4",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.4.tgz",
"integrity": "sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q==",
"requires": {
"@types/node": "*"
}
},
"@types/node": {
"version": "14.6.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.6.2.tgz",
@ -304,11 +320,18 @@
"integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA=="
},
"axios": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz",
"integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==",
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz",
"integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==",
"requires": {
"follow-redirects": "1.5.10"
"follow-redirects": "^1.10.0"
},
"dependencies": {
"follow-redirects": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz",
"integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA=="
}
}
},
"balanced-match": {
@ -1034,6 +1057,11 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
},
"eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
},
"expand-brackets": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz",
@ -1654,6 +1682,68 @@
"toidentifier": "1.0.0"
}
},
"http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"requires": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
}
},
"http-proxy-middleware": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-1.0.6.tgz",
"integrity": "sha512-NyL6ZB6cVni7pl+/IT2W0ni5ME00xR0sN27AQZZrpKn1b+qRh+mLbBxIq9Cq1oGfmTc7BUq4HB77mxwCaxAYNg==",
"requires": {
"@types/http-proxy": "^1.17.4",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"lodash": "^4.17.20",
"micromatch": "^4.0.2"
},
"dependencies": {
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"requires": {
"fill-range": "^7.0.1"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"requires": {
"to-regex-range": "^5.0.1"
}
},
"is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"micromatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz",
"integrity": "sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==",
"requires": {
"braces": "^3.0.1",
"picomatch": "^2.0.5"
}
},
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"requires": {
"is-number": "^7.0.0"
}
}
}
},
"http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
@ -2474,6 +2564,11 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.0.tgz",
"integrity": "sha512-ASCL5U13as7HhOExbT6OlWJJUV/lLzL2voOSP1UVehpRD8FbSrSDjfScK/KwAvVTI5AS6r4VwbOMlIqtvRidnA=="
},
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
},
"node-gyp": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz",
@ -3052,6 +3147,11 @@
"resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz",
"integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ=="
},
"querystring": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
"integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA="
},
"random-bytes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
@ -3206,6 +3306,11 @@
}
}
},
"requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8="
},
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",

@ -8,11 +8,13 @@
"test": "ts-node node_modules/jasmine/bin/jasmine --config=test/jasmine.json"
},
"dependencies": {
"axios": "^0.21.0",
"body-parser": "^1.18.3",
"chai": "^4.2.0",
"chai-http": "^4.3.0",
"express": "^4.16.4",
"express-session": "^1.17.1",
"http-proxy-middleware": "^1.0.6",
"jasmine": "^3.5.0",
"js-sha512": "^0.8.0",
"knex": "^0.21.5",
@ -20,11 +22,13 @@
"mssql": "^6.2.1",
"mysql": "^2.18.1",
"mysql2": "^2.1.0",
"node-fetch": "^2.6.1",
"nodemon": "^2.0.4",
"oracledb": "^5.0.0",
"passport": "^0.4.1",
"passport-local": "^1.0.0",
"pg": "^8.3.3",
"querystring": "^0.2.0",
"sqlite3": "^5.0.0",
"ts-node": "^8.10.2",
"typescript": "~3.7.2"

@ -0,0 +1,127 @@
const chai = require('chai');
const chaiHttp = require('chai-http');
const express = require('express');
import { SetupApp } from '../../../app';
import * as helpers from './helpers';
import { sha512 } from 'js-sha512';
import { IntegrationType } from '../../../../client/src/api';
async function init() {
chai.use(chaiHttp);
const app = express();
const knex = await helpers.initTestDB();
// Add test users.
await knex.insert({ email: "test1@test.com", passwordHash: sha512('pass1') }).into('users');
await knex.insert({ email: "test2@test.com", passwordHash: sha512('pass2') }).into('users');
SetupApp(app, knex, '');
// Login as a test user.
var agent = chai.request.agent(app);
await agent
.post('/login?username=' + encodeURIComponent("test1@test.com") + '&password=' + encodeURIComponent('pass1'))
.send({});
return agent;
}
describe('POST /integration with missing or wrong data', () => {
it('should fail', async done => {
let agent = await init();
let req = agent.keepOpen();
try {
await helpers.createIntegration(req, { type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 400);
await helpers.createIntegration(req, { name: "A", details: {}, secretDetails: {} }, 400);
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, secretDetails: {} }, 400);
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, }, 400);
await helpers.createIntegration(req, { name: "A", type: "NonexistentType", details: {}, secretDetails: {} }, 400);
} finally {
req.close();
agent.close();
done();
}
});
});
describe('POST /integration with a correct request', () => {
it('should succeed', async done => {
let agent = await init();
let req = agent.keepOpen();
try {
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 });
} finally {
req.close();
agent.close();
done();
}
});
});
describe('PUT /integration with a correct request', () => {
it('should succeed', async done => {
let agent = await init();
let req = agent.keepOpen();
try {
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 });
await helpers.modifyIntegration(req, 1, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' }, secretDetails: {} }, 200);
await helpers.checkIntegration(req, 1, 200, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: { secret: 'cat' } })
} finally {
req.close();
agent.close();
done();
}
});
});
describe('PUT /integration with wrong data', () => {
it('should fail', async done => {
let agent = await init();
let req = agent.keepOpen();
try {
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 });
await helpers.modifyIntegration(req, 1, { name: "B", type: "UnknownType", details: {}, secretDetails: {} }, 400);
} finally {
req.close();
agent.close();
done();
}
});
});
describe('DELETE /integration with a correct request', () => {
it('should succeed', async done => {
let agent = await init();
let req = agent.keepOpen();
try {
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 });
await helpers.checkIntegration(req, 1, 200, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} })
await helpers.deleteIntegration(req, 1, 200);
await helpers.checkIntegration(req, 1, 404);
} finally {
req.close();
agent.close();
done();
}
});
});
describe('GET /integration list with a correct request', () => {
it('should succeed', async done => {
let agent = await init();
let req = agent.keepOpen();
try {
await helpers.createIntegration(req, { name: "A", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 1 });
await helpers.createIntegration(req, { name: "B", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 2 });
await helpers.createIntegration(req, { name: "C", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} }, 200, { id: 3 });
await helpers.listIntegrations(req, 200, [
{ id: 1, name: "A", type: IntegrationType.SpotifyClientCredentials, details: {} },
{ id: 2, name: "B", type: IntegrationType.SpotifyClientCredentials, details: {} },
{ id: 3, name: "C", type: IntegrationType.SpotifyClientCredentials, details: {} },
]);
} finally {
req.close();
agent.close();
done();
}
});
});

@ -1,5 +1,6 @@
import { expect } from "chai";
import { sha512 } from "js-sha512";
import { IntegrationType } from "../../../../client/src/api";
export async function initTestDB() {
// Allow different database configs - but fall back to SQLite in memory if necessary.
@ -28,6 +29,7 @@ export async function createSong(
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
});
}
@ -42,6 +44,7 @@ export async function modifySong(
.send(props)
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
return res;
});
}
@ -56,6 +59,7 @@ export async function checkSong(
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
})
}
@ -71,6 +75,7 @@ export async function createArtist(
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
});
}
@ -85,6 +90,7 @@ export async function modifyArtist(
.send(props)
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
return res;
});
}
@ -99,6 +105,7 @@ export async function checkArtist(
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
})
}
@ -114,6 +121,7 @@ export async function createTag(
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
});
}
@ -128,6 +136,7 @@ export async function modifyTag(
.send(props)
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
return res;
});
}
@ -142,6 +151,7 @@ export async function checkTag(
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
})
}
@ -157,6 +167,7 @@ export async function createAlbum(
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
});
}
@ -171,6 +182,7 @@ export async function modifyAlbum(
.send(props)
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
return res;
});
}
@ -185,6 +197,7 @@ export async function checkAlbum(
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
})
}
@ -203,6 +216,7 @@ export async function createUser(
});
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
}
export async function login(
@ -217,6 +231,7 @@ export async function login(
.send({});
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
}
export async function logout(
@ -229,4 +244,78 @@ export async function logout(
.send({});
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
}
export async function createIntegration(
req,
props = { name: "Integration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} },
expectStatus = undefined,
expectResponse = undefined
) {
await req
.post('/integration')
.send(props)
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
});
}
export async function modifyIntegration(
req,
id = 1,
props = { name: "NewIntegration", type: IntegrationType.SpotifyClientCredentials, details: {}, secretDetails: {} },
expectStatus = undefined,
) {
await req
.put('/integration/' + id)
.send(props)
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
return res;
});
}
export async function checkIntegration(
req,
id,
expectStatus = undefined,
expectResponse = undefined,
) {
await req
.get('/integration/' + id)
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
})
}
export async function listIntegrations(
req,
expectStatus = undefined,
expectResponse = undefined,
) {
await req
.get('/integration')
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
expectResponse && expect(res.body).to.deep.equal(expectResponse);
return res;
})
}
export async function deleteIntegration(
req,
id,
expectStatus = undefined,
) {
await req
.delete('/integration/' + id)
.then((res) => {
expectStatus && expect(res).to.have.status(expectStatus);
return res;
})
}
Loading…
Cancel
Save