diff --git a/README.md b/README.md index 2c95c69..6286b15 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,21 @@
- Project License - GitHub Repo size - GitHub Repo issues - Top Languages - GitHub Repo filecount + Project License + GitHub Repo size + GitHub Repo issues + Top Languages + GitHub Repo filecount
- GitHub Repo stars - GitHub watchers - GitHub forks - GitHub commit activity + GitHub Repo stars + GitHub watchers + GitHub forks + GitHub commit activity
- GitHub all releases - GitHub release (latest by date) - Latest release - App Version + GitHub all releases + GitHub release (latest by date) + Latest release + App Version
# Development Branch @@ -38,6 +38,7 @@ The [hosted version](https://music.janishutz.com) of this MusicPlayer, which is # Features +**NOTICE:** This section has not been updated for the new version of MusicPlayer yet - Browser based App that runs on all OS (Linux, MacOS, Windows, iOS, Android, iPadOS, ...) - Fully featured Music Player - Show all song information over the Internet on any amount of client displays diff --git a/backend/config.schema.json b/backend/config.schema.json index 4a0a782..3be4780 100644 --- a/backend/config.schema.json +++ b/backend/config.schema.json @@ -24,5 +24,10 @@ "type": "string", "description": "The URL to the frontend. Just the host, in form https://music.janishutz.com" } - } + }, + "required": [ + "mode", + "clientMode", + "webUrl" + ] } diff --git a/backend/config/config.json b/backend/config/config.json index 18b2732..d40d273 100644 --- a/backend/config/config.json +++ b/backend/config/config.json @@ -1,4 +1,6 @@ { "$schema": "../config.schema.json", - "mode": "foss" + "mode": "foss", + "clientMode": "sse", + "webUrl": "http://localhost:8081" } diff --git a/backend/package.json b/backend/package.json index fe880c8..1986e3c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,6 +3,7 @@ "@janishutz/login-sdk-server": "^1.4.0", "@janishutz/login-sdk-server-stubs": "^1.4.0", "@janishutz/store-sdk": "^1.2.1", + "body-parser": "^2.3.0", "cors": "^2.8.6", "express": "^5.2.1", "express-session": "^1.19.0", @@ -12,6 +13,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.10.0", + "@types/body-parser": "^1.19.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/express-session": "^1.19.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index 636c28e..30816a5 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -2,35 +2,42 @@ import { getLoginSdk, getStoreSdk } from './sdk'; +import { + Config +} from './dtype/config'; import devtoken from './routes/devtoken'; import express from 'express'; import fs from 'fs'; import path from 'path'; - -// TODO: configurable -const sdk = getLoginSdk( false ); -const storeSdk = getStoreSdk( false ); - +import room from './routes/room'; +import user from './routes/user'; const run = () => { - const app = express(); const sdkConfig = JSON.parse( fs.readFileSync( path.join( __dirname, - '/config/sdk.config.testing.json' + '/../config/sdk.config.testing.json' ) ).toString() ); + const config = JSON.parse( fs.readFileSync( path.join( + __dirname, + '/../config/config.json' + ) ).toString() ) as Config; + const foss = config.mode !== 'hosted'; + const sdk = getLoginSdk( foss ); + const storeSdk = getStoreSdk( foss ); + const app = express(); // Load id.janishutz.com SDK and allow signing in sdk.setUp( { 'prod': false, 'service': { - 'serviceID': 'jh-music', + 'serviceID': sdkConfig[ 'name' ], 'serviceToken': sdkConfig[ 'token' ] }, 'user-agent': sdkConfig[ 'ua' ], 'sessionType': 'memory', - 'frontendURL': 'https://music.janishutz.com', - 'corsWhitelist': [ 'https://music.janishutz.com' ], + 'frontendURL': config.webUrl, + 'corsWhitelist': [ config.webUrl ], 'recheckTimeout': 300 * 1000, 'advancedVerification': 'sdk' }, @@ -55,22 +62,26 @@ const run = () => { // Load store sdk const storeConfig = JSON.parse( fs.readFileSync( path.join( __dirname, - '/config/store-sdk.config.secret.json' + '/../config/store-sdk.config.testing.json' ) ).toString() ); storeSdk.configure( storeConfig ); app.get( '/', ( _request: express.Request, response: express.Response ) => { - response.send( 'HELLO WORLD' ); + response.redirect( config.webUrl ?? 'https://music.janishutz.com' ); } ); + // TODO: Need way for frontend to get the connection type for shares + // Load extra routes - devtoken.routes( app ); + devtoken.routes( app, foss ); + room.routes( app, foss ); + user.routes( app, foss, config ); app.use( ( _request: express.Request, response: express.Response ) => { - response.sendFile( path.join( __dirname, '' ) ); + response.sendStatus( 404 ); } ); const PORT = process.env.PORT || 8080; diff --git a/backend/src/corsManager.ts b/backend/src/corsManager.ts new file mode 100644 index 0000000..8e14fc4 --- /dev/null +++ b/backend/src/corsManager.ts @@ -0,0 +1,56 @@ +import { + CorsOptions +} from 'cors'; +import cors from 'cors'; + +let corsWhitelist: string[] = []; + +const configure = ( whitelist: string[] ) => { + corsWhitelist = whitelist; +}; + +const middleware = ( rest: boolean = false ) => { + return cors( genCorsOpts( rest ) ); +}; + +/** + * Generate the options for the CORS library + * @param rest - [OPTIONAL] Whether to allow REST requests or not + * @returns CORS options object that can be used for the cors library + */ +const genCorsOpts = ( rest: boolean = false ): CorsOptions => { + const corsOpts: CorsOptions = { + 'credentials': true, + 'optionsSuccessStatus': 200, + 'origin': ( origin, cb ) => { + const status = blockREST( origin! ); + + cb( null, status ); + } + }; + + if ( rest ) { + corsOpts[ 'origin' ] = ( origin, cb ) => { + const status = allowREST( origin! ); + + cb( null, status ); + }; + } + + return corsOpts; +}; + +const blockREST = ( origin: string ): boolean => { + return corsWhitelist.includes( origin ); +}; + +const allowREST = ( origin: string ): boolean => { + return blockREST( origin ) || !origin; +}; + + +export default { + genCorsOpts, + configure, + middleware +}; diff --git a/backend/src/dtype/config.d.ts b/backend/src/dtype/config.d.ts new file mode 100644 index 0000000..9444f5c --- /dev/null +++ b/backend/src/dtype/config.d.ts @@ -0,0 +1,5 @@ +export interface Config { + 'mode': 'foss' | 'hosted'; + 'clientMode': 'sse' | 'poll'; + 'webUrl': string; +} diff --git a/backend/src/logger.ts b/backend/src/logger.ts index a2f3c20..4cd9fe3 100644 --- a/backend/src/logger.ts +++ b/backend/src/logger.ts @@ -2,27 +2,27 @@ import { writeFile } from 'node:fs'; -const log = ( ...msg: string[] ) => { +const log = ( ...msg: unknown[] ) => { output( 'log', log.caller.toString(), ...msg ); }; -const info = ( ...msg: string[] ) => { +const info = ( ...msg: unknown[] ) => { output( 'info', log.caller.toString(), ...msg ); }; -const debug = ( ...msg: string[] ) => { +const debug = ( ...msg: unknown[] ) => { output( 'debug', log.caller.toString(), ...msg ); }; -const warn = ( ...msg: string[] ) => { +const warn = ( ...msg: unknown[] ) => { output( 'warn', log.caller.toString(), ...msg ); }; -const error = ( ...msg: string[] ) => { +const error = ( ...msg: unknown[] ) => { output( 'error', log.caller.toString(), ...msg ); }; -const fatal = ( ...msg: string[] ) => { +const fatal = ( ...msg: unknown[] ) => { output( 'fatal', log.caller.toString(), ...msg ); }; @@ -51,7 +51,7 @@ const configure = ( location: 'stderr' | 'file', minLevel: LogLevel, file?: stri const logfile: string[] = []; -const output = ( level: LogLevel, caller: string, ...message: string[] ) => { +const output = ( level: LogLevel, caller: string, ...message: unknown[] ) => { if ( levels.indexOf( level ) < lev ) { return; } diff --git a/backend/src/manager/rooms/index.ts b/backend/src/manager/rooms/index.ts index 8ff011e..419e449 100644 --- a/backend/src/manager/rooms/index.ts +++ b/backend/src/manager/rooms/index.ts @@ -1,9 +1,178 @@ import { + Client, RoomStore } from './room'; +import { + PlaylistSongs +} from '../../dtype/file'; +import { + Response +} from 'express'; const rooms: RoomStore = {}; -const getRoom = ( room: string ) => {}; +/** + * The room to get + * @param room - The name of the room to get + * @returns The room, or undefined if it is not present + */ +const get = ( room: string ) => { + return rooms[room]; +}; -const createRoom = ( name: string ) => {}; +/** + * @param name - The name of the room to create + * @param uid - The ID of the user + */ +const create = ( name: string, uid: string ) => { + if ( rooms[ name ] ) { + return false; + } else { + rooms[ name ] = { + 'clients': [], + 'trackingClients': [], + 'owner': uid, + 'playlist': { + 'playlist': [], + 'lastUpdate': new Date().getTime() + }, + 'state': { + 'index': 0, + 'lastUpdate': new Date().getTime(), + 'playing': false, + 'start': new Date().getTime() + } + }; + + return true; + } +}; + +/** + * Add a listening client to a room + * @param roomName - The room ID this client should belong to + * @param id - The ID of the client + * @param kind - The type of client that is to be removed + * @param response - The express response object + * @returns true if successful, false otherwise + */ +const addClient = ( roomName: string, id: string, kind: 'client' | 'trackingClient', response: Response ) => { + if ( !rooms[roomName] ) return false; + + const room = rooms[roomName][kind + 's' as 'clients' | 'trackingClients'] as Client[]; + + room.push( { + 'response': response, + 'id': id + } ); + + return true; +}; + +/** + * Remove a listening client by its ID + * @param roomName - The room ID this client belongs to + * @param id - The ID of the client + * @param kind - The type of client that is to be removed + * @returns true if successful, false otherwise + */ +const removeClient = ( roomName: string, id: string, kind: 'client' | 'trackingClient' ) => { + if ( !rooms[roomName] ) return false; + + const room = rooms[roomName][kind + 's' as 'clients' | 'trackingClients'] as Client[]; + const idx = room.findIndex( client => client.id === id ); + + if ( idx < 0 ) + return false; + + room.splice( idx, 1 ); + + return true; +}; + +/** + * Close the room and disconnect all clients + * @param name - The name of the room + * @param uid - The name of the owner of the room + */ +const close = ( name: string, uid: string ) => { + if ( !rooms[name] || rooms[name].owner !== uid ) return false; + + rooms[name].clients.forEach( client => client.response.send( 'data: closed\n\n' ) ); + delete rooms[name]; + + return true; +}; + +/** + * Update the state of the specified room + * @param room - The room to update the state for + * @param uid - The UserID of the user that created the room (i.e. the owner) + * @param playing - Set to true if the player is playing currently + * @param index - The current playback index + * @param start - The timestamp where playback of the current song started + * @returns true if update succeeded + */ +const updateState = ( room: string, uid: string, playing: boolean, index: number, start: number ) => { + if ( !rooms[room] || rooms[room].owner !== uid ) return false; + + rooms[room].state = { + 'playing': playing, + 'index': index, + 'lastUpdate': new Date().getTime(), + 'start': start + }; + + return sendUpdate( room, 'state' ); +}; + +/** + * Update the playlist of the specified room + * @param room - The room to update the state for + * @param uid - The UserID of the user that created the room (i.e. the owner) + * @param playlist - The playlist that is currently playing + * @returns true if update succeeded + */ +const updatePlaylist = ( room: string, uid: string, playlist: PlaylistSongs ) => { + if ( !rooms[room] || rooms[room].owner !== uid ) return false; + + rooms[room].playlist = { + 'playlist': playlist, + 'lastUpdate': new Date().getTime() + }; + + return sendUpdate( room, 'playlist' ); +}; + +const sendUpdate = ( room: string, kind: 'state' | 'playlist' ) => { + const roomObject = rooms[room]; + + if ( !roomObject ) return false; + + roomObject.clients.forEach( client => client.response.write( `data: ${ { + 'type': kind, + 'data': JSON.stringify( roomObject[kind] ) + } }\n\n` ) ); +}; + +/** + * Send an update from the trackking system to all admin clients + * @param room - The room to send it to + * @param update - The update to send + */ +const sendTrackingUpdate = ( room: string, update: string ) => { + if ( !rooms[room] ) return false; + + rooms[room].trackingClients.forEach( client => client.response.write( `data: ${ update }\n\n` ) ); +}; + +export default { + get, + create, + close, + updateState, + updatePlaylist, + sendTrackingUpdate, + addClient, + removeClient +}; diff --git a/backend/src/manager/rooms/room.d.ts b/backend/src/manager/rooms/room.d.ts index 8cbc383..3d85963 100644 --- a/backend/src/manager/rooms/room.d.ts +++ b/backend/src/manager/rooms/room.d.ts @@ -1,24 +1,30 @@ import { - Playlist + PlaylistSongs } from '../../dtype/file'; import { Response } from 'express'; +export interface Client { + 'id': string; + 'response': Response; +} + export interface Room { - 'playlist': Playlist; - 'playing': boolean; - 'index': number; - 'start': number; - 'token': string; - 'lastUpdate': number; + 'playlist': { + 'lastUpdate': number; + 'playlist': PlaylistSongs; + } + 'state': { + 'index': number; + 'start': number; + 'playing': boolean; + 'lastUpdate': number; + }; + 'owner': string; + 'clients': Client[]; + 'trackingClients': Client[]; - /** - * This is used to determine if the playlist needs to be sent in polling mode - */ - 'lastPlaylistUpdate': number; - - 'clients': Response[] } export interface RoomStore { diff --git a/backend/src/manager/rooms/sse.ts b/backend/src/manager/rooms/sse.ts new file mode 100644 index 0000000..0ebf976 --- /dev/null +++ b/backend/src/manager/rooms/sse.ts @@ -0,0 +1,33 @@ +import express from 'express'; +import { + generateToken +} from '../../token'; +import rooms from '.'; + +export const sseMiddleware = ( kind: 'client' | 'trackingClient' ) => { + return ( request: express.Request, response: express.Response ) => { + if ( typeof request.params.id !== 'string' ) + return response.sendStatus( 400 ); + + const room = rooms.get( request.params.id ); + + if ( !room ) response.sendStatus( 404 ); + + response.writeHead( 200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + } ); + response.status( 200 ); + response.flushHeaders(); + response.write( 'data: connected\n\n' ); + + const token = generateToken( 20 ); + + rooms.addClient( request.params.id, token, kind, response ); + + request.on( 'close', () => { + rooms.removeClient( request.params.id as string, token, kind ); + } ); + }; +}; diff --git a/backend/src/manager/users/index.ts b/backend/src/manager/users/index.ts index e69de29..150239e 100644 --- a/backend/src/manager/users/index.ts +++ b/backend/src/manager/users/index.ts @@ -0,0 +1,47 @@ +import { + readFile, + stat, + writeFile +} from 'node:fs'; +import { + UserPlaylistFile +} from '../../dtype/file'; +import path from 'node:path'; + +export const getUserFile = async ( uid: string ): Promise => { + return new Promise( ( resolve, reject ) => { + readFile( getUserFilePath( uid ), ( err, data ) => { + if ( err ) return reject( err ); + + resolve( JSON.parse( String( data ) ) as UserPlaylistFile ); + } ); + } ); +}; + +export const getUserFilePath = ( uid: string ): string => { + return path.join( __dirname, '/../../../data/', uid + '.json' ); +}; + +export const testUserFileExists = async ( uid: string ): Promise => { + return new Promise( resolve => { + stat( getUserFilePath( uid ), err => { + if ( err ) return resolve( false ); + + resolve( true ); + } ); + } ); +}; + +export const writeUserFile = async ( uid: string, file: UserPlaylistFile ) => { + writeFile( getUserFilePath( uid ), JSON.stringify( file ), err => { + if ( err ) return Promise.reject( err ); + else Promise.resolve(); + } ); +}; + +export default { + getUserFilePath, + getUserFile, + writeUserFile, + testUserFileExists +}; diff --git a/backend/src/manager/users/ownership.ts b/backend/src/manager/users/ownership.ts new file mode 100644 index 0000000..f637cae --- /dev/null +++ b/backend/src/manager/users/ownership.ts @@ -0,0 +1,44 @@ +import { + getLoginSdk, + getStoreSdk +} from '../../sdk'; +import express from 'express'; + +const cache = {}; + +export const getOwnershipManager = ( foss: boolean ) => { + const storeSdk = getStoreSdk( foss ); + const sdk = getLoginSdk( foss ); + + const getOwned = async ( request: express.Request ) => { + const uid = sdk.getUID( request )!; + + if ( cache[ uid ] ) { + return true; + } else { + const now = new Date().getTime(); + + if ( ( await storeSdk.getSubscriptions( uid ) ).findIndex( + sub => ( sub.id === 'com.janishutz.MusicPlayer.subscription' || sub.id === 'com.janishutz.MusicPlayer.subscription-month' ) && sub.expires - now > 0 + ) > -1 ) { + cache[ uid ] = true; + + return true; + } else { + return true; + } + } + }; + + const middleware = () => { + return async ( request: express.Request, response: express.Response, next: express.NextFunction ) => { + if ( !await getOwned( request ) ) response.sendStatus( 402 ); + else next(); + }; + }; + + return { + getOwned, + middleware + }; +}; diff --git a/backend/src/routes/devtoken.ts b/backend/src/routes/devtoken.ts index 1d5354e..1c3540d 100644 --- a/backend/src/routes/devtoken.ts +++ b/backend/src/routes/devtoken.ts @@ -1,10 +1,17 @@ -import cors from 'cors'; +import corsManager from '../corsManager'; import express from 'express'; import fs from 'fs'; +import { + getLoginSdk +} from '../sdk'; +import { + getOwnershipManager +} from '../manager/users/ownership'; import jwt from 'jsonwebtoken'; import path from 'path'; -const routes = ( app: express.Application ) => { +const routes = ( app: express.Application, foss: boolean ) => { + const sdk = getLoginSdk( foss ); const privateKey = fs.readFileSync( path.join( __dirname, '/../config/apple_private_key.p8' @@ -13,27 +20,31 @@ const routes = ( app: express.Application ) => { __dirname, '/../config/apple-music-api.config.secret.json' ) ).toString() ); + const ownership = getOwnershipManager( foss ); - app.get( '/dev-token', cors( { - 'origin': 'http://localhost:8081', - 'credentials': true - } ), ( _request: express.Request, response: express.Response ) => { - // sign dev token - const now = new Date().getTime(); - const tomorrow = now + ( 24 * 3600 * 1000 ); - const jwtToken = jwt.sign( { - 'iss': musicApiConfig.teamID, - 'iat': Math.floor( now / 1000 ), - 'exp': Math.floor( tomorrow / 1000 ) - }, privateKey, { - 'algorithm': 'ES256', - 'keyid': musicApiConfig.keyID - } ); + app.get( + '/dev-token', + corsManager.middleware( false ), + sdk.loginCheck(), + ownership.middleware(), + ( _request: express.Request, response: express.Response ) => { + // sign dev token + const now = new Date().getTime(); + const tomorrow = now + ( 24 * 3600 * 1000 ); + const jwtToken = jwt.sign( { + 'iss': musicApiConfig.teamID, + 'iat': Math.floor( now / 1000 ), + 'exp': Math.floor( tomorrow / 1000 ) + }, privateKey, { + 'algorithm': 'ES256', + 'keyid': musicApiConfig.keyID + } ); - response.send( jwtToken ); - } ); + response.send( jwtToken ); + } + ); }; export default { routes -} +}; diff --git a/backend/src/routes/room/index.ts b/backend/src/routes/room/index.ts index 93f82a4..c9d563b 100644 --- a/backend/src/routes/room/index.ts +++ b/backend/src/routes/room/index.ts @@ -1,18 +1,47 @@ import express from 'express'; +import { + getLoginSdk +} from '../../sdk'; +import { + getOwnershipManager +} from '../../manager/users/ownership'; +import rooms from '../../manager/rooms'; import tracking from './tracking'; import update from './update'; -const routes = ( app: express.Application ) => { - tracking.routes( app ); - update.routes( app ); +const routes = ( app: express.Application, foss: boolean ) => { + const sdk = getLoginSdk( foss ); + const ownership = getOwnershipManager( foss ); - app.get( '/room/create', ( request: express.Request, response: express.Response ) => { + tracking.routes( app, foss ); + update.routes( app, foss ); - } ); + app.get( + '/room/create', + sdk.loginCheck(), + ownership.middleware(), + ( request: express.Request, response: express.Response ) => { + if ( !request.query.room ) return response.sendStatus( 400 ); - app.get( '/room/:id/close', ( request: express.Request, response: express.Response ) => { + if ( rooms.create( String( request.query.room ), sdk.getUID( request )! ) ) + response.sendStatus( 200 ); + else + response.sendStatus( 500 ); + } + ); - } ); + app.get( + '/room/:id/close', + sdk.loginCheck(), + ( request: express.Request, response: express.Response ) => { + if ( typeof request.params.room !== 'string' ) return response.sendStatus( 400 ); + + if ( rooms.close( String( request.query.room ), sdk.getUID( request )! ) ) + response.sendStatus( 200 ); + else + response.sendStatus( 500 ); + } + ); }; export default { diff --git a/backend/src/routes/room/tracking.ts b/backend/src/routes/room/tracking.ts index 7132378..2df844d 100644 --- a/backend/src/routes/room/tracking.ts +++ b/backend/src/routes/room/tracking.ts @@ -1,22 +1,46 @@ +import corsManager from '../../corsManager'; import express from 'express'; +import { + getLoginSdk +} from '../../sdk'; +import rooms from '../../manager/rooms'; +import { + sseMiddleware +} from '../../manager/rooms/sse'; -const routes = ( app: express.Application ) => { - app.get( '/room/:id/tracking/exit', ( request: express.Request, response: express.Response ) => { +const tackingUpdate = ( message: string ) => { + return ( request: express.Request, response: express.Response ) => { + if ( typeof request.params.id === 'string' ) { + if ( rooms.sendTrackingUpdate( request.params.id, message ) ) + response.sendStatus( 200 ); + else + response.sendStatus( 500 ); + } else + response.sendStatus( 400 ); + }; +}; - } ); +const routes = ( app: express.Application, foss: boolean ) => { + const sdk = getLoginSdk( foss ); - app.get( '/room/:id/tracking/ping', ( request: express.Request, response: express.Response ) => { + app.get( + '/room/:id/tracking/exit', + corsManager.middleware( false ), + tackingUpdate( 'exit' ) + ); - } ); + app.get( + '/room/:id/tracking/ping', + corsManager.middleware( false ), + tackingUpdate( 'ping' ) + ); - app.get( '/room/:id/tracking/connect', ( request: express.Request, response: express.Response ) => { - - } ); - - app.get( '/room/:id/admin', ( request: express.Request, response: express.Response ) => { - // Endpoint for the streamer to connect to, all above events are sent there - - } ); + app.get( + '/room/:id/admin', + corsManager.middleware( false ), + sdk.loginCheck(), + sseMiddleware( 'client' ) + ); }; export default { diff --git a/backend/src/routes/room/update.ts b/backend/src/routes/room/update.ts index 2cc6b4d..812ebae 100644 --- a/backend/src/routes/room/update.ts +++ b/backend/src/routes/room/update.ts @@ -1,21 +1,91 @@ +import bodyParser from 'body-parser'; +import corsManager from '../../corsManager'; import express from 'express'; +import { + getLoginSdk +} from '../../sdk'; +import logger from '../../logger'; +import rooms from '../../manager/rooms'; +import { + sseMiddleware +} from '../../manager/rooms/sse'; -const routes = ( app: express.Application ) => { - app.get( '/room/:id/connect', ( request: express.Request, response: express.Response ) => { +const routes = ( app: express.Application, foss: boolean ) => { + const sdk = getLoginSdk( foss ); + app.get( '/room/:id/connect', corsManager.middleware( false ), sseMiddleware( 'client' ) ); + + app.get( '/room/:id/poll', corsManager.middleware( false ), ( request: express.Request, response: express.Response ) => { + if ( typeof request.params.id !== 'string' ) + return response.sendStatus( 400 ); + + const room = rooms.get( request.params.id ); + + if ( !room ) response.sendStatus( 404 ); + + const lastRequest = parseInt( String( request.query.last ) ) ?? 0; + + response.send( + JSON.stringify( { + 'state': room.state, + 'playlist': lastRequest < room.playlist.lastUpdate ? room.playlist : undefined + } ) + ); } ); - app.get( '/room/:id/poll', ( request: express.Request, response: express.Response ) => { + app.options( '/room/:id/update/playlist', corsManager.middleware( false ) ); + app.post( + '/room/:id/update/playlist', + corsManager.middleware( false ), + sdk.loginCheck(), + bodyParser.json(), + ( request: express.Request, response: express.Response ) => { + if ( typeof request.params.id !== 'string' ) + return response.sendStatus( 400 ); - } ); + try { + if ( rooms.updatePlaylist( + request.params.id, + sdk.getUID( request )!, + request.body.playlist ? JSON.parse( request.body.playlist ) : [] + ) ) + response.sendStatus( 200 ); + else + response.sendStatus( 400 ); + } catch ( err ) { + logger.error( 'Failed sending updated playlists with error', err ); + response.sendStatus( 500 ); + } + } + ); - app.get( '/room/:id/update/playlist', ( request: express.Request, response: express.Response ) => { + app.options( '/room/:id/update/state', corsManager.middleware( false ) ); + app.post( + '/room/:id/update/state', + corsManager.middleware( false ), + sdk.loginCheck(), + bodyParser.json(), + ( request: express.Request, response: express.Response ) => { + if ( typeof request.params.id !== 'string' ) + return response.sendStatus( 400 ); - } ); - - app.get( '/room/:id/update/state', ( request: express.Request, response: express.Response ) => { - - } ); + try { + if ( rooms.updateState( + request.params.id, + sdk.getUID( request )!, + request.body.playing ?? false, + request.body.index ?? -1, + request.body.start ?? new Date().getTime() + ) ) + response.sendStatus( 200 ); + else + response.sendStatus( 400 ); + } catch ( err ) { + logger.error( 'Failed sending updated state with error', err ); + response.sendStatus( 500 ); + } + } + ); }; export default { diff --git a/backend/src/routes/user/index.ts b/backend/src/routes/user/index.ts index b30205d..7c02636 100644 --- a/backend/src/routes/user/index.ts +++ b/backend/src/routes/user/index.ts @@ -1,11 +1,82 @@ +import { + Playlist, + UserPlaylistFile +} from '../../dtype/file'; +import { + getUserFile, + writeUserFile +} from '../../manager/users'; +import { + Config +} from '../../dtype/config'; +import bodyParser from 'body-parser'; import express from 'express'; +import { + getLoginSdk +} from '../../sdk'; +import { + getOwnershipManager +} from '../../manager/users/ownership'; +import logger from '../../logger'; -const routes = ( app: express.Application ) => { - app.get( '/user/playlists', ( request: express.Request, response: express.Response ) => { +const routes = ( app: express.Application, foss: boolean, config: Config ) => { + const sdk = getLoginSdk( foss ); + const ownership = getOwnershipManager( foss ); + app.get( '/user/owned', sdk.loginCheck(), async ( request: express.Request, response: express.Response ) => { + if ( await ownership.getOwned( request ) ) + response.send( JSON.stringify( { + 'status': true, + 'shareMode': config.clientMode + } ) ); + else + response.send( JSON.stringify( { + 'status': false, + 'shareMode': config.clientMode + } ) ); } ); - app.post( '/user/playlists', ( request: express.Request, response: express.Response ) => { + app.get( + '/user/playlists', + sdk.loginCheck(), + async ( request: express.Request, response: express.Response ) => { + try { + const playlists = await getUserFile( sdk.getUID( request )! ); - } ); + response.send( JSON.stringify( playlists.playlists ) ); + } catch ( e ) { + logger.error( 'Failed retrieving user file with error', e ); + } + } + ); + + app.post( + '/user/playlists', + sdk.loginCheck(), + ownership.middleware(), + bodyParser.json(), + async ( request: express.Request, response: express.Response ) => { + try { + const contents: UserPlaylistFile = { + 'playlists': request.body as Playlist[], + 'userid': sdk.getUID( request )!, + 'version': '1' + }; + + try { + await writeUserFile( contents.userid, contents ); + response.sendStatus( 200 ); + } catch ( e ) { + logger.error( 'Failed updating user playlists', e ); + response.sendStatus( 500 ); + } + } catch ( e ) { + logger.error( 'Failed retrieving user file with error', e ); + } + } + ); +}; + +export default { + routes }; diff --git a/backend/src/sdk/store-sdk-stub.ts b/backend/src/sdk/store-sdk-stub.ts index ec97683..70ca946 100644 --- a/backend/src/sdk/store-sdk-stub.ts +++ b/backend/src/sdk/store-sdk-stub.ts @@ -5,16 +5,24 @@ interface StoreSDKConfig { 'loglevel': 'debug' | 'info' | 'log' | 'warn' | 'error' | 'none'; } +interface SubscriptionData { + 'id': string; + 'status': string; + 'expires': number; +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars -const getSubscriptions = ( _uid: string ) => { +const getSubscriptions = async ( _uid: string ): Promise => { return [ { 'id': 'com.janishutz.MusicPlayer.subscription', 'expires': new Date().getTime() + 200000, - 'status': true + 'status': 'active' } ]; }; -const configure = ( _config: StoreSDKConfig ) => {} +const configure = ( config: StoreSDKConfig ) => { + console.log( config ); +}; export default { getSubscriptions, diff --git a/backend/src/token.ts b/backend/src/token.ts new file mode 100644 index 0000000..db2c2bf --- /dev/null +++ b/backend/src/token.ts @@ -0,0 +1,35 @@ +import crypto from 'node:crypto'; + +/** + * Generate a token for e.g. login, etc + * @param length - The number of characters of the token + * @returns The generated token + */ +export const generateToken = ( length: number ): string => { + let token = ''; + + const min = 45; + const max = 122; + + for ( let i = 0; i < length; i++ ) { + const buf = new Uint8Array( 1 ); + + crypto.getRandomValues( buf ); + let randomNumber = Math.floor( ( buf[ 0 ] / 256 * ( max - min ) ) + min ); + + while ( + ( 58 < randomNumber && randomNumber < 63 ) + || ( 90 < randomNumber && randomNumber < 95 ) + || ( 95 < randomNumber && randomNumber < 97 ) + ) { + const buf = new Uint8Array( 1 ); + + crypto.getRandomValues( buf ); + randomNumber = Math.floor( ( buf[ 0 ] / 256 * ( max - min ) ) + min ); + } + + token += String.fromCharCode( randomNumber ); + } + + return token; +};