diff --git a/client/package-lock.json b/client/package-lock.json index b8c35fe..74d3f63 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 1fb3ac0..7c82c01 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/App.tsx b/client/src/App.tsx index 08a6107..45f7fa5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ( - - - + ); } diff --git a/client/src/api.ts b/client/src/api.ts index d1cb334..92333f1 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -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"; \ No newline at end of file +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; +} \ No newline at end of file diff --git a/client/src/assets/spotify_icon.svg b/client/src/assets/spotify_icon.svg new file mode 100644 index 0000000..cfc993b --- /dev/null +++ b/client/src/assets/spotify_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/components/MainWindow.tsx b/client/src/components/MainWindow.tsx index 2241615..ff2110a 100644 --- a/client/src/components/MainWindow.tsx +++ b/client/src/components/MainWindow.tsx @@ -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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } \ No newline at end of file diff --git a/client/src/components/appbar/AppBar.tsx b/client/src/components/appbar/AppBar.tsx index b39601f..7b6f394 100644 --- a/client/src/components/appbar/AppBar.tsx +++ b/client/src/components/appbar/AppBar.tsx @@ -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: { > {auth.user?.email || "Unknown user"} + { + props.onClose(); + history.replace('/settings') + }} + >User Settings { props.onClose(); diff --git a/client/src/components/common/StoreLinkIcon.tsx b/client/src/components/common/StoreLinkIcon.tsx index 7359ab0..8b3478e 100644 --- a/client/src/components/common/StoreLinkIcon.tsx +++ b/client/src/components/common/StoreLinkIcon.tsx @@ -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 ; + case ExternalStore.Spotify: + return ; default: throw new Error("Unknown external store: " + whichStore) } diff --git a/client/src/components/windows/Windows.tsx b/client/src/components/windows/Windows.tsx index 9188b2f..b07b792 100644 --- a/client/src/components/windows/Windows.tsx +++ b/client/src/components/windows/Windows.tsx @@ -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 {} + }, } \ No newline at end of file diff --git a/client/src/components/windows/album/AlbumWindow.tsx b/client/src/components/windows/album/AlbumWindow.tsx index 7d14329..28ef06d 100644 --- a/client/src/components/windows/album/AlbumWindow.tsx +++ b/client/src/components/windows/album/AlbumWindow.tsx @@ -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 && } diff --git a/client/src/components/windows/artist/ArtistWindow.tsx b/client/src/components/windows/artist/ArtistWindow.tsx index f9008cc..513a556 100644 --- a/client/src/components/windows/artist/ArtistWindow.tsx +++ b/client/src/components/windows/artist/ArtistWindow.tsx @@ -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 && } diff --git a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx index 9e4dd7e..e6e6c62 100644 --- a/client/src/components/windows/manage_tags/ManageTagsWindow.tsx +++ b/client/src/components/windows/manage_tags/ManageTagsWindow.tsx @@ -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); 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: Failed to save changes: {e.message}, diff --git a/client/src/components/windows/settings/IntegrationSettings.tsx b/client/src/components/windows/settings/IntegrationSettings.tsx new file mode 100644 index 0000000..dfb7ade --- /dev/null +++ b/client/src/components/windows/settings/IntegrationSettings.tsx @@ -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 + + props.onChangeClientId(e.target.value)} + /> + + + { + props.onChangeClientSecret(e.target.value) + }} + onFocus={(e: any) => { + if (props.clientSecret === null) { + // Change from dots to empty input + console.log("Focus!") + props.onChangeClientSecret(''); + } + }} + /> + + ; +} + +// 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 = { + [serverApi.IntegrationType.SpotifyClientCredentials]: + + + {IntegrationClasses[serverApi.IntegrationType.SpotifyClientCredentials].getIcon({ + style: { height: '40px', width: '40px' } + })} + + Spotify (using Client Credentials) + + } + let IntegrationDescription: Record = { + [serverApi.IntegrationType.SpotifyClientCredentials]: + + 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.
+ 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. +
+ } + + return + + + + {IntegrationDescription[props.integration.type]} + + props.onChange && props.onChange({ + ...props.integration, + name: e.target.value, + })} + /> + + {props.integration.type === serverApi.IntegrationType.SpotifyClientCredentials && + 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} + + + {props.showEditButton && } + {props.showSubmitButton && props.onSubmit && props.onSubmit(props.integration)} + >} + {props.showDeleteButton && } + {props.showCancelButton && } + {props.showTestButton && } + + +} + +let EditorWithTest = (props: any) => { + const [testFlashMessage, setTestFlashMessage] = + React.useState(undefined); + let { integration, ...rest } = props; + return { + integration.integration.test({}) + .then(() => { + setTestFlashMessage( + Integration is active. + ) + }) + }} + 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 + { + props.onAdd && props.onAdd(serverApi.IntegrationType.SpotifyClientCredentials); + props.onClose && props.onClose(); + }} + >Spotify + +} + +function EditIntegrationDialog(props: { + open: boolean, + onClose?: () => void, + upstreamId?: number, + integration: IntegrationState, + onSubmit?: (p: serverApi.CreateIntegrationRequest) => void, + isNew: boolean, +}) { + let [editingIntegration, setEditingIntegration] = + useState(props.integration); + + useEffect(() => { setEditingIntegration(props.integration); }, [props.integration]); + + return + Edit Integration + { + setEditingIntegration({ + ...editingIntegration, + properties: i, + integration: makeIntegration(i, editingIntegration.id), + }); + }} + /> + +} + +export default function IntegrationSettings(props: {}) { + const [addMenuPos, setAddMenuPos] = React.useState(null); + const [editingState, setEditingState] = React.useState(null); + + let { + state: integrations, + addIntegration, + modifyIntegration, + deleteIntegration, + updateFromUpstream, + } = useIntegrations(); + + const onOpenAddMenu = (e: any) => { + setAddMenuPos([e.clientX, e.clientY]) + }; + const onCloseAddMenu = () => { + setAddMenuPos(null); + }; + + return <> + + {integrations === null && } + {Array.isArray(integrations) && + {integrations.map((state: IntegrationState) => + { setEditingState(state); }} + onDelete={() => { + deleteIntegration(state.id) + .then(updateFromUpstream) + }} + /> + )} + + + + } + + { + let p = makeDefaultIntegrationProperties(type); + setEditingState({ + properties: p, + integration: makeIntegration(p, -1), + id: -1, + }) + }} + /> + {editingState && { 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) + } + }} + />} + ; +} \ No newline at end of file diff --git a/client/src/components/windows/settings/SettingsWindow.tsx b/client/src/components/windows/settings/SettingsWindow.tsx new file mode 100644 index 0000000..1fe79aa --- /dev/null +++ b/client/src/components/windows/settings/SettingsWindow.tsx @@ -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 +} + +export function SettingsWindowControlled(props: { + state: SettingsWindowState, + dispatch: (action: any) => void, +}) { + let history: any = useHistory(); + let auth: Auth = useAuth(); + + return + + + + User Settings + Integrations + + + + + +} \ No newline at end of file diff --git a/client/src/lib/backend/integrations.tsx b/client/src/lib/backend/integrations.tsx new file mode 100644 index 0000000..f4468ff --- /dev/null +++ b/client/src/lib/backend/integrations.tsx @@ -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; +} \ No newline at end of file diff --git a/client/src/lib/backend/queries.tsx b/client/src/lib/backend/queries.tsx index d93ef40..d6c1aa9 100644 --- a/client/src/lib/backend/queries.tsx +++ b/client/src/lib/backend/queries.tsx @@ -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; diff --git a/client/src/lib/backend/request.tsx b/client/src/lib/backend/request.tsx new file mode 100644 index 0000000..b5fa5f5 --- /dev/null +++ b/client/src/lib/backend/request.tsx @@ -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 { + 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(); +} \ No newline at end of file diff --git a/client/src/lib/backend/tags.tsx b/client/src/lib/backend/tags.tsx index 67146e9..0c3ea72 100644 --- a/client/src/lib/backend/tags.tsx +++ b/client/src/lib/backend/tags.tsx @@ -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()), diff --git a/client/src/lib/integration/Integration.tsx b/client/src/lib/integration/Integration.tsx new file mode 100644 index 0000000..441262f --- /dev/null +++ b/client/src/lib/integration/Integration.tsx @@ -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 {} + + // Requires feature: GetSongs + async getSongs(getSongsParams: any): Promise { return []; } + + // Requires feature: SearchSongs + async searchSong(songProps: IntegrationSong): Promise { return []; } + + // Requires feature: SearchAlbum + async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; } + + // Requires feature: SearchArtist + async searchArtist(artistProps: IntegrationArtist): Promise { return []; } +} \ No newline at end of file diff --git a/client/src/lib/integration/spotify/SpotifyClientCreds.tsx b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx new file mode 100644 index 0000000..d211907 --- /dev/null +++ b/client/src/lib/integration/spotify/SpotifyClientCreds.tsx @@ -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 + } + + 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 { return []; } + async searchAlbum(albumProps: IntegrationAlbum): Promise { return []; } + async searchArtist(artistProps: IntegrationArtist): Promise { return []; } + + async search(query: string, type: SearchType): + Promise { + 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, + }, + } + }) + } + } + } +} \ No newline at end of file diff --git a/client/src/lib/integration/useIntegrations.tsx b/client/src/lib/integration/useIntegrations.tsx new file mode 100644 index 0000000..883b772 --- /dev/null +++ b/client/src/lib/integration/useIntegrations.tsx @@ -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, + deleteIntegration: (id: number) => Promise, + modifyIntegration: (id: number, v: serverApi.CreateIntegrationRequest) => Promise, + updateFromUpstream: () => Promise, +}; + +export const IntegrationClasses: Record = { + [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({ + state: [], + addIntegration: async () => 0, + modifyIntegration: async () => { }, + deleteIntegration: async () => { }, + updateFromUpstream: async () => { }, +}); + +export function ProvideIntegrations(props: { children: any }) { + const integrations = useProvideIntegrations(); + return {props.children}; +} + +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, + } +} \ No newline at end of file diff --git a/client/src/lib/saveChanges.tsx b/client/src/lib/saveChanges.tsx index db2205f..666c5af 100644 --- a/client/src/lib/saveChanges.tsx +++ b/client/src/lib/saveChanges.tsx @@ -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); } diff --git a/client/src/lib/useAuth.tsx b/client/src/lib/useAuth.tsx index d6b4995..f25dff4 100644 --- a/client/src/lib/useAuth.tsx +++ b/client/src/lib/useAuth.tsx @@ -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: + } + } + return null; +} + function useProvideAuth() { - const [user, setUser] = useState(null); + const [user, setUser] = useState(loadAuth()); // TODO: password maybe shouldn't be encoded into the URL. const signin = (email: string, password: string) => { @@ -59,6 +88,7 @@ function useProvideAuth() { icon: , } setUser(user); + persistAuth(user); return user; })(); }; @@ -89,6 +119,7 @@ function useProvideAuth() { throw new Error("Failed to log out."); } setUser(null); + persistAuth(null); })(); }; diff --git a/server/app.ts b/server/app.ts index 878a4ab..acab868 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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(); }); diff --git a/server/endpoints/ModifyAlbum.ts b/server/endpoints/Album.ts similarity index 51% rename from server/endpoints/ModifyAlbum.ts rename to server/endpoints/Album.ts index 829f6c2..5442db7 100644 --- a/server/endpoints/ModifyAlbum.ts +++ b/server/endpoints/Album.ts @@ -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 { diff --git a/server/endpoints/AlbumDetails.ts b/server/endpoints/AlbumDetails.ts deleted file mode 100644 index 635b210..0000000 --- a/server/endpoints/AlbumDetails.ts +++ /dev/null @@ -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); -} -} \ No newline at end of file diff --git a/server/endpoints/ModifyArtist.ts b/server/endpoints/Artist.ts similarity index 50% rename from server/endpoints/ModifyArtist.ts rename to server/endpoints/Artist.ts index 3d56ee6..0363423 100644 --- a/server/endpoints/ModifyArtist.ts +++ b/server/endpoints/Artist.ts @@ -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 { diff --git a/server/endpoints/ArtistDetails.ts b/server/endpoints/ArtistDetails.ts deleted file mode 100644 index effe19f..0000000 --- a/server/endpoints/ArtistDetails.ts +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/server/endpoints/CreateAlbum.ts b/server/endpoints/CreateAlbum.ts deleted file mode 100644 index 7172d5b..0000000 --- a/server/endpoints/CreateAlbum.ts +++ /dev/null @@ -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(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/CreateArtist.ts b/server/endpoints/CreateArtist.ts deleted file mode 100644 index 0496c47..0000000 --- a/server/endpoints/CreateArtist.ts +++ /dev/null @@ -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(); - } - }); -} \ No newline at end of file diff --git a/server/endpoints/CreateSong.ts b/server/endpoints/CreateSong.ts deleted file mode 100644 index a3c80c1..0000000 --- a/server/endpoints/CreateSong.ts +++ /dev/null @@ -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(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/CreateTag.ts b/server/endpoints/CreateTag.ts deleted file mode 100644 index 1587bcf..0000000 --- a/server/endpoints/CreateTag.ts +++ /dev/null @@ -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(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/DeleteTag.ts b/server/endpoints/DeleteTag.ts deleted file mode 100644 index 3fd9ae3..0000000 --- a/server/endpoints/DeleteTag.ts +++ /dev/null @@ -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(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/Integration.ts b/server/endpoints/Integration.ts new file mode 100644 index 0000000..6e32c46 --- /dev/null +++ b/server/endpoints/Integration.ts @@ -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(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/MergeTag.ts b/server/endpoints/MergeTag.ts deleted file mode 100644 index ed776c5..0000000 --- a/server/endpoints/MergeTag.ts +++ /dev/null @@ -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(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/ModifySong.ts b/server/endpoints/ModifySong.ts deleted file mode 100644 index 488b72f..0000000 --- a/server/endpoints/ModifySong.ts +++ /dev/null @@ -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(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/ModifyTag.ts b/server/endpoints/ModifyTag.ts deleted file mode 100644 index 6596958..0000000 --- a/server/endpoints/ModifyTag.ts +++ /dev/null @@ -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(); - } - }) -} \ No newline at end of file diff --git a/server/endpoints/Query.ts b/server/endpoints/Query.ts index 60111f3..f705516 100644 --- a/server/endpoints/Query.ts +++ b/server/endpoints/Query.ts @@ -258,7 +258,7 @@ async function getFullTag(knex: Knex, userId: number, tag: any): Promise { 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), diff --git a/server/endpoints/RegisterUser.ts b/server/endpoints/RegisterUser.ts index 0782d90..1b4d824 100644 --- a/server/endpoints/RegisterUser.ts +++ b/server/endpoints/RegisterUser.ts @@ -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), diff --git a/server/endpoints/Song.ts b/server/endpoints/Song.ts new file mode 100644 index 0000000..0583b5f --- /dev/null +++ b/server/endpoints/Song.ts @@ -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 = 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 = 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 = 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(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/SongDetails.ts b/server/endpoints/SongDetails.ts deleted file mode 100644 index 24e6267..0000000 --- a/server/endpoints/SongDetails.ts +++ /dev/null @@ -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 = 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 = 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 = 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) - } -} \ No newline at end of file diff --git a/server/endpoints/Tag.ts b/server/endpoints/Tag.ts new file mode 100644 index 0000000..673ec0d --- /dev/null +++ b/server/endpoints/Tag.ts @@ -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(); + } + }) +} \ No newline at end of file diff --git a/server/endpoints/TagDetails.ts b/server/endpoints/TagDetails.ts deleted file mode 100644 index daacaae..0000000 --- a/server/endpoints/TagDetails.ts +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/server/integrations/integrations.ts b/server/integrations/integrations.ts new file mode 100644 index 0000000..4b109d6 --- /dev/null +++ b/server/integrations/integrations.ts @@ -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': '' }, + // }) + // ) +} \ No newline at end of file diff --git a/server/migrations/20201113155620_add_integrations.ts b/server/migrations/20201113155620_add_integrations.ts new file mode 100644 index 0000000..08dbc43 --- /dev/null +++ b/server/migrations/20201113155620_add_integrations.ts @@ -0,0 +1,24 @@ +import * as Knex from "knex"; + + +export async function up(knex: Knex): Promise { + // 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 { + await knex.schema.dropTable('integrations'); +} + diff --git a/server/package-lock.json b/server/package-lock.json index 8b45277..7d49ca9 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 762d412..a05b69b 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/test/integration/flows/IntegrationFlow.js b/server/test/integration/flows/IntegrationFlow.js new file mode 100644 index 0000000..cd693c0 --- /dev/null +++ b/server/test/integration/flows/IntegrationFlow.js @@ -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(); + } + }); +}); \ No newline at end of file diff --git a/server/test/integration/flows/helpers.js b/server/test/integration/flows/helpers.js index bbf427e..12677cd 100644 --- a/server/test/integration/flows/helpers.js +++ b/server/test/integration/flows/helpers.js @@ -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; + }) } \ No newline at end of file