Skip to content

Commit

Permalink
Merge pull request #76 from echo-lab/develop
Browse files Browse the repository at this point in the history
Admin control, user security
  • Loading branch information
Noam-Bendelac authored Jan 10, 2021
2 parents bb683c9 + e5f589d commit e45c5c6
Show file tree
Hide file tree
Showing 14 changed files with 460 additions and 158 deletions.
23 changes: 16 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
# name of host server; format like 'http://localhost' (no port or slash)
# hostname of backend server; format like 'http://localhost' (no port or slash)
HOST_NAME=
# port to listen on; set BACKEND_PORT to this in client/.env when developing with client/ $ npm start
PORT=
# when developing with client/ $ npm start, this is the PORT you set in client/.env; otherwise optional
FRONTEND_PORT=

# 'development' if developing with client/ $ npm start, 'production' otherwise
NODE_ENV=
# required if NODE_ENV=development, ignored otherwise; this is the hostname:port
# serving the frontend; port is the PORT you set in client/.env; e.g. 'http://localhost:8888'
FRONTEND_ADDRESS=

# from spotify developer dashboard
CLIENT_ID=
# from spotify developer dashboard
CLIENT_SECRET=

# spotify account id of account that owns all the playlists
OWNER_ACCOUNT_ID=
# refresh token for the owner account
OWNER_ACCOUNT_REFRESH_TOKEN=
# passcode for making admin requests to server, must include in cookie 'admin_key'
ADMIN_KEY=

# path to playlist and user ids csv, usually starts with 'db/...'
DB_IDS=
# path to playlists collection, usually starts with 'db/...'
DB_PLAYLISTS=
# 'development' or 'production'
NODE_ENV=
# for debugging auth flow, probably not needed (optional)
API_TARGET=
# path to users collection, usually starts with 'db/...'
DB_USERS=
2 changes: 1 addition & 1 deletion client/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# both of these are only needed when developing with (client) npm start
# port to listen on; set FRONTEND_PORT to this in ../.env
# port to listen on; use this for FRONTEND_ADDRESS in ../.env
PORT=
# this is the PORT you set in ../.env
BACKEND_PORT=
70 changes: 34 additions & 36 deletions client/src/PlaylistGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ import { classes, colors } from './styles'
import { useHover } from './useHover'
import { Link } from 'react-router-dom'
import { useResource, fetchWrapper, Resource } from './fetchWrapper'
import { GetPlaylistsResponse, PlaylistSimple } from './shared/apiTypes'



type Playlists = SpotifyApi.PlaylistObjectSimplified[]

export const usePlaylists = (): Resource<Playlists> => {
const [resource, setter] = useResource<Playlists>(null, true)
export const usePlaylists = (): Resource<GetPlaylistsResponse> => {
const [resource, setter] = useResource<GetPlaylistsResponse>(null, true)

useEffect(() => {
(async () => {
// setter({ loading: true })
const response = await fetchWrapper('/api/playlists/')
const response = await fetchWrapper<GetPlaylistsResponse>('/api/playlists/')
setter({
loading: false,
...response,
Expand All @@ -33,7 +33,7 @@ export const PlaylistGrid = ({
}: {
style?: CSSProperties,
}) => {
const { data: result } = usePlaylists()
const { data: playlists, loading } = usePlaylists()

const playlistGridStyle: CSSProperties = {
...style,
Expand All @@ -44,17 +44,35 @@ export const PlaylistGrid = ({
}

return <div style={playlistGridStyle}>
{result && result.map((playlist, index) => <PlaylistCard key={index} item={playlist} />)}
{!loading && (
playlists.length
? playlists.map(playlist => <PlaylistCard key={playlist.id} playlist={playlist} />)
: <h2 style={classes.text}>You are not a member of any playlists.</h2>
)}
</div>
}


const PlaylistCard = ({ item }: { item: SpotifyApi.PlaylistObjectSimplified }) => {
const owner = item.owner?.display_name ?? '' // apparently not always present?

// if multiple images present, image [1] has the closest resolution; else
// use the only image
const image = item.images[1] ?? item.images[0]

const imageStyle = {
...classes.centeredClippedImage,
height: '18.0rem',
width: '18.0rem',
}
const textDivStyle = {
...classes.column,
flex: 1,
}
const nameStyle = {
...classes.text,
...classes.textOverflow(),
}
const usersStyle = {
...classes.text,
...classes.textOverflow(),
}

const PlaylistCard = ({ playlist }: { playlist: PlaylistSimple }) => {

const [isHovered, hoverProps] = useHover()

Expand All @@ -63,36 +81,16 @@ const PlaylistCard = ({ item }: { item: SpotifyApi.PlaylistObjectSimplified }) =
...(isHovered && { backgroundColor: colors.grayscale.darkGray }),
padding: '2.0rem',
}
const imageStyle = {
...classes.centeredClippedImage,
height: '18.0rem',
width: '18.0rem',
}
const textDivStyle = {
...classes.column,
flex: 1,
}
const nameStyle = {
...classes.text,
...classes.textOverflow(),
}
const ownerStyle = {
...classes.text,
...classes.textOverflow(),
}

// TODO The Link causes the search tab to be rerendered and thus lose its
// state (query); add state to Link? will changing structure of
// Routers/Switches make that not happen?
return <Link
to={`/playlists/${item.id}/`}
to={`/playlists/${playlist.id}/`}
style={playlistCardStyle}
{...hoverProps}
>
<img src={image.url} alt="" style={imageStyle} />
<img src={playlist.image} alt="" style={imageStyle} />
<div style={textDivStyle}>
<p style={nameStyle}>{item.name}</p>
<p style={ownerStyle}>{owner}</p>
<p style={nameStyle}>{playlist.name}</p>
<p style={usersStyle}>{playlist.users.join(', ')}</p>
</div>
</Link>
}
Expand Down
8 changes: 7 additions & 1 deletion client/src/shared/apiTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,11 @@ export interface GetTrackSearchResponse extends SpotifyApi.SearchResponse {

}

export type GetPlaylistsResponse = SpotifyApi.PlaylistObjectSimplified[]
export interface PlaylistSimple {
id: string,
users: string[], // ids or display names?
name: string,
image: string, // temporary source url from spotify
}
export type GetPlaylistsResponse = PlaylistSimple[]

14 changes: 14 additions & 0 deletions client/src/shared/dbTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,22 @@ export interface TrackObject {
* Chat can be empty array if no messages/actions yet or if in situated mode
*/
export interface PlaylistDocument extends Document {
users: string[], // many-to-many
tracks: TrackObject[],
chat: SeparateChatEvent[], // separate
chatMode: 'situated' | 'separate' | 'hybrid',
}





/**
* An entry in the Users collection
* keeps track of the playlists a user belongs to
*/
export interface UserDocument extends Document {
playlists: string[], // many-to-many
}


22 changes: 22 additions & 0 deletions ids.csv.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# format of each line:
# playlist_id,chat_mode,user_id1,user_id2,...# comments for convenience

# chat_mode can be `situated`, `separate`, or `hybrid`
# playlist_id and chat_mode are required; you can have 0 or more user_ids
# trailing commas are ok
# spaces are not ok

# for convenience you could list (in comments!) the display names of all
# the playlist ids and user ids here to help you keep track of them:
# "Playlist name": playlist_id
# "User name": user_id
# remember the same user id can show up in multiple playlists if they're
# involved in multiple groups

# to remove a playlist / make a playlist inaccessible to all users, you MUST
# still list the playlist here but remove all user_ids. if you leave out a
# playlist it will be unaffected and still be accessible to users

# line example if playlist id is js3n12p, mode is hybrid, user ids are p1428np,
# 72k7l65, and grhj35a:
# js3n12p,hybrid,p1428np,72k7l65,grhj35a# comment if needed
150 changes: 150 additions & 0 deletions src/admin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@


import { Application } from 'express'
import { playlistsDB, usersDB } from './db'
import { initializePlaylist } from './initializePlaylist'
import { spotifyApi } from './ownerAccount'
import { parseIdsCsv } from './parseIdsCsv'



export const setupAdmin = (app: Application) => {

/**
* log admin requests
*/
app.use('/admin/', (req, res, next) => {
console.log(`${req.method} ${req.originalUrl} request`)
next()
})



/**
* ensure admin requests have admin key
*/
app.use('/admin/', (req, res, next) => {
if (req.cookies.admin_key !== process.env.ADMIN_KEY) {
res.sendStatus(403)
return
}
next()
})


/**
* for testing curl, admin_key, etc
*/
app.get('/admin/test', (req, res) => {
res.sendStatus(200)
})




/**
* endpoint for telling server to read ids.csv and update the DBs
*/
app.post('/admin/load-ids', async (req, res) => {
try {
const { byPlaylist, byUser } = await parseIdsCsv(process.env.DB_IDS)

/**
* for each playlist in configData:
* - if playlistId found in db:
* - set chatMode; all data inside is valid for all chatModes so it's not complicated
* - set users array in playlist, push playlist for each user
* - if user is new to playlist, do anything like a separate chat event?
* - if user is removed from playlist, do anything? TODO ask team what should be done in frontend
* - considering this bc of possible future features like listing names, color coding
* - activeUsers and archivedUsers arrays?
* - else:
* - initializePlaylist
* - remove call from get playlist endpoint
* - in playlist endpoint, if dbPlaylist or spotifyPlaylist not found, error
* TODO make the promises parallel
*/
for (const config of byPlaylist) {
const dbPlaylist = await playlistsDB.findOne({ _id: config.playlistId })
if (dbPlaylist) {
// playlistId found in db
// TODO to do things with new/removed users, compare config.userIds to
// dbPlaylist.users here
await playlistsDB.update({ _id: config.playlistId }, {
$set: {
chatMode: config.chatMode,
users: config.userIds,
}
})
} else {
// playlist must exist in spotify, otherwise there's an error
await initializePlaylist(
(await spotifyApi.getPlaylist(config.playlistId)).body,
config
)

// for testing with fake playlist ids, replace above line with below:
// await initializePlaylist({ tracks: { items: [
// { track: { id: 'mock track' }, added_by: { id: 'mock user' } }
// ]}} as SpotifyApi.SinglePlaylistResponse, config)
}
}

// fetch all users currently in db
const users = await usersDB.find({})

// a user can either be in the config but not the db, in the db but not
// the config, or in both

// for each user in the config, either set playlists or insert into db
for (const [userId, playlists] of byUser) {
// not using the upsert feature of nedb because i don't know how
// reliable it is with promisify
if (users.filter(user => user._id === userId).length) {
// the user exists in the db
await usersDB.update({ _id: userId }, { $set: { playlists } })
} else {
await usersDB.insert({ _id: userId, playlists })
}
}

// we missed all the db users who aren't mentioned in the config
for (const { _id: userId } of users) {
// for all users, if they were not listed in the config then set
// playlists to []
if (!byUser.has(userId)) {
await usersDB.update({ _id: userId }, { $set: { playlists: [] }})
}
}

// manually stringify json to make spacing human readable
res.type('application/json')
res.send(JSON.stringify({byPlaylist, byUser: [...byUser]}, null, 2))

} catch (e) {
console.error(e)
if (e.type === 'invalid chatMode') {
res.status(400).send(e.error.message)
} else if (e.code === 'ENOENT') {
// don't naively send e.error.message, which has the full file path
res.send(`File ${process.env.DB_IDS} not found`)
} else {
res.end()
}
}
})



/**
* catch all other admin requests
*/
app.all(['/admin', '/admin/*'], (req, res) => {
console.log(`${req.path} not found`)
res.sendStatus(404)
})


}


Loading

0 comments on commit e45c5c6

Please sign in to comment.