Skip to content

Commit 45bca86

Browse files
authored
Merge pull request #3 from streamwall/isPinned
Adds ability to pin streams
2 parents cd62e3c + 051daa1 commit 45bca86

File tree

5 files changed

+184
-9
lines changed

5 files changed

+184
-9
lines changed

README.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,42 @@
11
# StreamSource
2+
Streamsource is a publicly readable API to store and retrieve information about livestreams across many streaming platforms.
3+
4+
## Getting Started
5+
**If you just want to use the API to read stream data found at [streams.streamwall.io](http://streams.streamwall.io), see [API Reference](#api-reference)**
6+
7+
### Current state
8+
Streamsource is in active development at a very early stage. Do not consider the API stable. We don't even have versioning yet! (we accept PRs!)
9+
10+
Many assumptions are built in, and this application is tightly coupled to a few different services. This will improve over time.
11+
12+
### Installation
13+
14+
1. Clone this repository
15+
1. `npm install`
16+
1. Install Postgres and create a database, a user, etc.
17+
1. Copy example.env to just .env and update your configuration settings
18+
1. `npx sequelize-cli db:migrate`
19+
20+
### Running
21+
22+
1. Make sure Postgres is running
23+
1. Start server: `node bin/www`
24+
1. Preview streams json: http://localhost:3000/streams
25+
26+
### Upgrading
27+
28+
1. Get new code: `git pull`
29+
1. Apply migrations: `npx sequelize-cli db:migrate`
30+
1. Restart server: `node bin/www`
31+
32+
### Development and contributing
33+
34+
This project is in its infancy. We're open to pull requests and will work with you to get improvements merged.
235

336
## API Reference
37+
438
### Authentication
5-
Certain routes are protected with a JWT that you must include in your Authorization header when making authenticated requests.
39+
Most routes are protected with a JWT that you must include in your Authorization header when making authenticated requests.
640

741
API tokens can be obtained by creating a user and POSTing to /users/login, which will generate and return a token.
842

@@ -11,6 +45,17 @@ Subsequent authenticated requests must include the following header:
1145
Authorization: Bearer MYTOKEN
1246
```
1347

48+
#### Getting Started with Authentication
49+
1. Create your user
50+
```
51+
curl -d "[email protected]&password=abc123" -X POST http://localhost:3000/users/signup
52+
```
53+
2. Log in
54+
```
55+
curl -d "[email protected]&password=abc123" -X POST http://localhost:3000/users/login
56+
```
57+
3. Save the token in your app/script/bot's configuration file (keep it secret!)
58+
1459
### POST /users/signup
1560
Creates a new user
1661
@@ -67,6 +112,7 @@ Note: All string searches are case-insensitive and queried based on `ILIKE '%YOU
67112
|link|String|The URL of a stream|
68113
|status|String|One of: `['Live', 'Offline', 'Unknown']`|
69114
|notStatus|String|Exclude this status. One of: `['Live', 'Offline', 'Unknown']`|
115+
|isPinned|Boolean|Defaults to null. When true, prevents state changes, e.g. updates to `isExpired` or `status`|
70116
|isExpired|Boolean|Streams are considered expired when they are no longer active. Default: false|
71117
|title|String|Title of a stream|
72118
|notTitle|String|Title of a stream|
@@ -94,6 +140,7 @@ curl http://localhost:3000/streams?city=seattle
94140
"link": "https://www.instagram.com/future_crystals/live",
95141
"status": "Live",
96142
"title": "",
143+
"isPinned": false,
97144
"isExpired": false,
98145
"checkedAt": "2020-09-25T04:58:52.840Z",
99146
"liveAt": "2020-09-25T04:58:52.840Z",
@@ -121,6 +168,7 @@ Create a new stream.
121168
"link": "https://www.instagram.com/future_crystals/live",
122169
"status": "Live",
123170
"title": "",
171+
"isPinned": false,
124172
"isExpired": false,
125173
"checkedAt": "2020-09-25T04:58:52.840Z",
126174
"liveAt": "2020-09-25T04:58:52.840Z",
@@ -145,6 +193,7 @@ Get details for a single stream
145193
"link": "https://www.instagram.com/future_crystals/live",
146194
"status": "Live",
147195
"title": "",
196+
"isPinned": false,
148197
"isExpired": false,
149198
"checkedAt": "2020-09-25T04:58:52.840Z",
150199
"liveAt": "2020-09-25T04:58:52.840Z",
@@ -184,3 +233,31 @@ Expire a stream
184233
...
185234
}
186235
```
236+
### PUT /streams/:id/pin
237+
Pin a stream; prevents state changes while pinned
238+
- **Requires authentication**
239+
- **Requires privileged role: Editor or Admin**
240+
```
241+
curl -X PUT http://localhost:3000/streams/1/pin --header 'Authorization: Bearer MYTOKEN'
242+
```
243+
```json
244+
{
245+
...
246+
"isPinned": true,
247+
...
248+
}
249+
```
250+
### DELETE /streams/:id/pin
251+
Unpin a stream
252+
- **Requires authentication**
253+
- **Requires privileged role: Editor or Admin**
254+
```
255+
curl -X PUT http://localhost:3000/streams/1/pin --header 'Authorization: Bearer MYTOKEN'
256+
```
257+
```json
258+
{
259+
...
260+
"isPinned": false,
261+
...
262+
}
263+
```

example.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ DB_SSL=false
99
# Set to 0 for connecting to a production DB from local
1010
NODE_TLS_REJECT_UNAUTHORIZED=1
1111

12-
# Generate your own sufficiently long, sufficiently random secret
12+
# Generate your own sufficiently long, sufficiently random secret (just a random string)
1313
JWT_SECRET=YOURSECRET
1414

1515
# Sign up for an account at https://logdna.com/
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use strict';
2+
3+
module.exports = {
4+
up: async (queryInterface, Sequelize) => {
5+
return queryInterface.sequelize.transaction(t => {
6+
return Promise.all([
7+
queryInterface.addColumn('Streams', 'isPinned', {
8+
type: Sequelize.DataTypes.BOOLEAN,
9+
}, { transaction: t }),
10+
queryInterface.addIndex('Streams', ['isPinned'], {
11+
fields: 'isPinned',
12+
transaction: t,
13+
}),
14+
]);
15+
});
16+
},
17+
18+
down: async (queryInterface, Sequelize) => {
19+
return queryInterface.sequelize.transaction(t => {
20+
return Promise.all([
21+
queryInterface.removeColumn('Streams', 'isPinned', { transaction: t }),
22+
]);
23+
});
24+
}
25+
};

models/stream.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ module.exports = (sequelize, DataTypes) => {
2929
// However, we know that multiple streamers sometimes
3030
// use the same name to stream, e.g., Bear Gang, Concrete Reporting, Unicorn Riot, Boop Troop, etc.
3131
const matchingFilter = [
32-
{link: this.link},
32+
{ link: this.link },
3333
]
34-
if(this.source) {
34+
if (this.source) {
3535
matchingFilter.push({ source: this.source })
3636
}
3737
const pastStream = await Stream.findOne({
@@ -99,6 +99,7 @@ module.exports = (sequelize, DataTypes) => {
9999
}
100100
},
101101
postedBy: DataTypes.TEXT,
102+
isPinned: DataTypes.BOOLEAN,
102103
city: DataTypes.TEXT,
103104
region: DataTypes.TEXT,
104105
state: {

routes/streams.js

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ async function getStreams(req, res) {
6868
field: 'isExpired',
6969
rule: { [req.query.isExpired ? Op.is : Op.not]: true, }
7070
},
71+
isPinned: {
72+
field: 'isPinned',
73+
rule: { [req.query.isPinned ? Op.is : Op.not]: true, }
74+
},
7175
title: {
7276
field: 'title',
7377
rule: { [Op.iLike]: `%${req.query.title}%` }
@@ -173,7 +177,7 @@ async function getStreams(req, res) {
173177

174178
async function normalizeLink(link) {
175179
let normalizedLink = link
176-
normalizedLink = normalizedLink.replace(/\/$/,'')
180+
normalizedLink = normalizedLink.replace(/\/$/, '')
177181
normalizedLink = normalizedLink.replace(/https?:\/\/(www\.)?/i, '')
178182
return normalizedLink
179183
}
@@ -188,11 +192,11 @@ async function createStream(req, res) {
188192
const normalizedLink = await normalizeLink(req.body.link)
189193
const existingStream = await Stream.findOne({
190194
where: {
191-
link: { [Op.iLike]: `%${normalizedLink}%` },
195+
link: { [Op.iLike]: `%${normalizedLink}%` },
192196
isExpired: false,
193197
}
194198
})
195-
if(existingStream) {
199+
if (existingStream) {
196200
res.status(303).json({ data: existingStream })
197201
return
198202
}
@@ -223,6 +227,20 @@ async function patchStream(req, res) {
223227
const id = req.params.id
224228
const stream = await Stream.findByPk(id)
225229

230+
const statusIsChanging = req.body.status && req.body.status !== stream.status
231+
const isExpiredIsChanging = req.body.isExpired !== undefined && req.body.isExpired !== stream.isExpired
232+
const willChangeState = statusIsChanging || isExpiredIsChanging
233+
const pinInvalidatesStateChange = stream.isPinned && willChangeState
234+
if (pinInvalidatesStateChange) {
235+
const error = new Error("Stream is pinned and cannot alter 'isExpired' or 'status' states. Unpin this resource in order to make changes.")
236+
res.status(409).json({
237+
error: {
238+
message: error.message
239+
}
240+
})
241+
return
242+
}
243+
226244
const updatedStream = await stream.update({
227245
source: req.body.source,
228246
platform: req.body.platform,
@@ -235,7 +253,8 @@ async function patchStream(req, res) {
235253
city: req.body.city,
236254
region: req.body.region,
237255
checkedAt: req.body.checkedAt,
238-
liveAt: req.body.liveAt
256+
liveAt: req.body.liveAt,
257+
isPinned: req.body.isPinned,
239258
})
240259

241260
if (updatedStream instanceof ValidationError) {
@@ -265,6 +284,40 @@ async function getStream(req, res) {
265284
res.status(200).json(response)
266285
}
267286

287+
async function pinStream(req, res) {
288+
if (!req.user || !accessControl.can(req.user.role).updateAny('stream').granted) {
289+
res.status(401)
290+
return
291+
}
292+
293+
const id = req.params.id
294+
const stream = await Stream.findByPk(id)
295+
if (!stream) {
296+
res.status(404).send()
297+
}
298+
299+
await stream.update({ isPinned: true })
300+
301+
res.status(204).send()
302+
}
303+
304+
async function unpinStream(req, res) {
305+
if (!req.user || !accessControl.can(req.user.role).updateAny('stream').granted) {
306+
res.status(401)
307+
return
308+
}
309+
310+
const id = req.params.id
311+
const stream = await Stream.findByPk(id)
312+
if (!stream) {
313+
res.status(404).send()
314+
}
315+
316+
await stream.update({ isPinned: false })
317+
318+
res.status(204).send()
319+
}
320+
268321
async function expireStream(req, res) {
269322
if (!req.user || !accessControl.can(req.user.role).deleteAny('stream').granted) {
270323
res.status(401)
@@ -277,15 +330,34 @@ async function expireStream(req, res) {
277330
res.status(404).send()
278331
return
279332
}
333+
if (stream.isPinned) {
334+
const error = new Error("Stream is pinned and cannot alter 'isExpired' or 'status' states. Unpin this resource in order to make changes.")
335+
res.status(409).json({
336+
error: error,
337+
})
338+
}
339+
280340
await stream.update({ isExpired: true })
281341
res.status(204).send()
282342
}
283343

284-
/* GET streams listing. */
344+
// List streams
285345
router.get('/', getStreams)
346+
347+
// Create
286348
router.post('/', passport.authenticate('jwt', { session: false }), createStream)
349+
350+
// Pin / unpin streams. This is a field on the model; in RESTful terms, let's call this a "virtual resource". Think "pin" as in "thumbtack", not as a verb ;)
351+
router.put('/:id/pin', passport.authenticate('jwt', { session: false }), pinStream)
352+
router.delete('/:id/pin', passport.authenticate('jwt', { session: false }), unpinStream)
353+
354+
// Read
287355
router.get('/:id', getStream)
356+
357+
// Update
288358
router.patch('/:id', passport.authenticate('jwt', { session: false }), patchStream)
359+
360+
// Soft delete
289361
router.delete('/:id', passport.authenticate('jwt', { session: false }), expireStream)
290362

291363
module.exports = router;

0 commit comments

Comments
 (0)