diff --git a/examples/auth_code_no_server/index.html b/examples/auth_code_no_server/index.html new file mode 100644 index 0000000..4298c77 --- /dev/null +++ b/examples/auth_code_no_server/index.html @@ -0,0 +1,59 @@ + + + + Authentication with out a server | Template + + + + + +

This example is the "web side" of "OAuth authorization code flow" without a server.

+

Hnady for scripts on a raspberry pi, or server thats not practical to spool up a web server on to handle the ?code return.

+

It's not disimilar to how some of the Google Auth node examples work, you start a script, the script goes "open this URL in a browser", then you are given the ?code to copy paste into the script and the script fetchs the token and refresh token

+ +
+
+ + + + + +
+
+ + +
+
+ + +
+ +
+ +
+
+
+
+ + + + diff --git a/examples/ban_request_manager/README.md b/examples/ban_request_manager/README.md new file mode 100644 index 0000000..d6fdf2a --- /dev/null +++ b/examples/ban_request_manager/README.md @@ -0,0 +1,32 @@ +## What is this example + +An example project to manage unban requests on any channel the logged in user is a moderator on + +This is an example on how to + +- Obtain a token +- Use that token to get the channels that logged in user is a moderator for +- Upon selecting a channel, get the unban requests for that channel +- Update/Resolve the unban requests + +Resoling an unban request to "unban the user" will mark the request as accepted and unban the user, so you do not need to make a second API request to unban the user. + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/ban_request_manager/) + +## Reference Documentation + +- [Get Moderated Channels](https://dev.twitch.tv/docs/api/reference/#get-moderated-channels) +- [Get Unban Requests](https://dev.twitch.tv/docs/api/reference/#get-unban-requests) +- [Resolve Unban Request](https://dev.twitch.tv/docs/api/reference/#resolve-unban-requests) + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere diff --git a/examples/ban_request_manager/ban_manager.js b/examples/ban_request_manager/ban_manager.js new file mode 100644 index 0000000..9b4a643 --- /dev/null +++ b/examples/ban_request_manager/ban_manager.js @@ -0,0 +1,256 @@ +// go populate this with a client_id +var client_id = 'hozgh446gdilj5knsrsxxz8tahr3koz'; +var redirect = window.location.origin + '/twitch_misc/'; + +// setup a memory space for the token/userID +// and the state machine +var access_token = ''; +var user_id = ''; +var channel_id = ''; +var unban_request_id = ''; + +let status = document.getElementById('status'); + +// setup authorise link +document.getElementById('authorize').setAttribute('href', 'https://id.twitch.tv/oauth2/authorize?client_id=' + client_id + '&redirect_uri=' + encodeURIComponent(redirect) + '&response_type=token&scope=user:read:moderated_channels+moderator:read:unban_requests+moderator:manage:unban_requests'); + +async function processToken(token) { + access_token = token; + + status.textContent = 'Got Token. Loading Things'; + + // who are we + let user_resp = await fetch( + 'https://api.twitch.tv/helix/users', + { + method: 'GET', + headers: { + 'Client-ID': client_id, + 'Authorization': `Bearer ${access_token}`, + 'Accept': 'application/json' + } + } + ); + + if (user_resp.status != 200) { + status.textContent = `Failed to obtain User information ${user_resp.status} - ${await user_resp.text()}`; + return; + } + + let user_data = await user_resp.json(); + if (user_data.data.length != 1) { + status.textContent = `Failed to obtain a User`; + return; + } + + user_id = user_data.data[0].id; + status.textContent = `Hello ${user_id} - ${user_data.data[0].login}`; + + getPageChannels(); +} + +// get "targets" +async function getPageChannels(after) { + let channels_url = new URL('https://api.twitch.tv/helix/moderation/channels'); + let p = [ + [ 'user_id', user_id ], + [ 'first', 100 ] + ] + if (after) { + p.push([ 'after', after ]); + } + channels_url.search = new URLSearchParams(p); + let channels_req = await fetch( + channels_url, + { + method: 'GET', + headers: { + 'Client-ID': client_id, + 'Authorization': `Bearer ${access_token}`, + 'Accept': 'application/json' + } + } + ); + + if (channels_req.status != 200) { + status.textContent = `Failed to get channels you mod: ${channels_req.status} - ${await channels_req.text()}`; + return; + } + + let channels_resp = await channels_req.json(); + let { data, pagination } = channels_resp; + + // draw targets + channel_select.textContent = ''; + channel_select.removeAttribute('disabled'); + + let opt = document.createElement('option'); + opt.value = user_id; + opt.textContent = 'You'; + channel_select.append(opt); + + data.forEach(channel => { + let { broadcaster_id, broadcaster_name } = channel; + + let opt = document.createElement('option'); + opt.value = broadcaster_id; + opt.textContent = broadcaster_name; + channel_select.append(opt); + }); + + // next? + channel_id = user_id; + initIt(); +} + +channel_select.addEventListener('change', (e) => { + channel_id = e.target.value; + console.log('CID', channel_id); + status.textContent = `Loading into ${channel_id}`; + + initIt(); +}); + +async function initIt() { + // open up websocket for eventsub for real time + + resetUnbanRequests(); +} + +async function resetUnbanRequests() { + requests.textContent = ''; + // load current data + loadUnbanRequests(); +} +async function loadUnbanRequests(after) { + let unbans_url = new URL('https://api.twitch.tv/helix/moderation/unban_requests'); + let p = [ + [ 'broadcaster_id', channel_id ], + [ 'moderator_id', user_id ], + [ 'status', 'pending' ], + [ 'first', 100 ] + ] + if (after) { + p.push([ 'after', after ]); + } + unbans_url.search = new URLSearchParams(p); + + let unbans_req = await fetch( + unbans_url, + { + method: 'GET', + headers: { + 'Client-ID': client_id, + 'Authorization': `Bearer ${access_token}`, + 'Accept': 'application/json' + } + } + ); + + if (unbans_req.status != 200) { + status.textContent = `Failed to get requests: ${unbans_req.status} - ${await unbans_req.text()}`; + return; + } + + let { data, pagination } = await unbans_req.json(); + + data.forEach(unban => { + let { broadcaster_login, user_login, user_name } = unban; + let { id, text } = unban; + let { created_at } = unban; + + let dsp = ''; + + // add to le stack + let dat = new Date(created_at); + let y = dat.getFullYear(); + let m = dat.getMonth() + 1; + let d = dat.getDate(); + + dsp += `${y}/${m}/${d}`; + + dsp += ' '; + + let h = dat.getHours(); + if (h < 10) { h = `0${h}`; } + let i = dat.getMinutes(); + if (i < 10) { i = `0${i}`; } + dsp += `${h}:${i}`; + + let r = requests.insertRow(); + var c = r.insertCell(); + c.textContent = user_name; + var c = r.insertCell(); + c.textContent = dsp; + var c = r.insertCell(); + c.textContent = text; + + var c = r.insertCell(); + var b = document.createElement('a'); + b.href = `https://www.twitch.tv/popout/${broadcaster_login}/viewercard/${user_login}?popout=`; + b.textContent = 'UserCard'; + b.target = '_blank'; + c.append(b); + + var c = r.insertCell(); + var b = document.createElement('div'); + b.textContent = 'Act'; + b.classList.add('alink'); + b.setAttribute('data-ban-id', id); + c.append(b); + + b.addEventListener('click', (e) => { + act.showModal(); + //act_act.setAttribute('data-ban-id', e.target.getAttribute('data-ban-id')); + unban_request_id = e.target.getAttribute('data-ban-id'); + }); + }); +} + +act_accept.addEventListener('click', (e) => { + processRequest('approved'); +}); +act_reject.addEventListener('click', (e) => { + processRequest('denied'); +}); +act_noact.addEventListener('click', (e) => { + act.close(); + unban_request_id = ''; +}); + +async function processRequest(status) { + // add a spinner? + let resolution_text = resolution_text_text.value; + + let take_action_req = await fetch( + 'https://api.twitch.tv/helix/moderation/unban_requests', + { + method: 'PATCH', + headers: { + 'Client-ID': client_id, + 'Authorization': `Bearer ${access_token}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + broadcaster_id: channel_id, + moderator_id: user_id, + unban_request_id, + status, + resolution_text + }) + } + ); + + act.close(); + unban_request_id = ''; + + if (take_action_req.status != 200) { + status.textContent = `Failed to take action ${status} - ${take_action_req.status} - ${await take_action_req.text()}`; + return; + } + status.textContent = 'Completed the requested action'; + + // trigger reload? + resetUnbanRequests(); +} diff --git a/examples/ban_request_manager/eventsub.js b/examples/ban_request_manager/eventsub.js new file mode 100644 index 0000000..570bc0f --- /dev/null +++ b/examples/ban_request_manager/eventsub.js @@ -0,0 +1,207 @@ +class initSocket { + counter = 0 + closeCodes = { + 4000: 'Internal Server Error', + 4001: 'Client sent inbound traffic', + 4002: 'Client failed ping-pong', + 4003: 'Connection unused', + 4004: 'Reconnect grace time expired', + 4005: 'Network Timeout', + 4006: 'Network error', + 4007: 'Invalid Reconnect' + } + + constructor(connect) { + this._events = {}; + + if (connect) { + this.connect(); + } + } + + connect(url, is_reconnect) { + this.eventsub = {}; + this.counter++; + + url = url ? url : 'wss://eventsub.wss.twitch.tv/ws'; + is_reconnect = is_reconnect ? is_reconnect : false; + + log(`Connecting to ${url}|${is_reconnect}`); + this.eventsub = new WebSocket(url); + this.eventsub.is_reconnecting = is_reconnect; + this.eventsub.counter = this.counter; + + this.eventsub.addEventListener('open', () => { + log(`Opened Connection to Twitch`); + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event + // https://github.com/Luka967/websocket-close-codes + this.eventsub.addEventListener('close', (close) => { + console.log('EventSub close', close, this.eventsub); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Connection Closed: ${close.code} Reason - ${this.closeCodes[close.code]}`); + + if (!this.eventsub.is_reconnecting) { + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Is not reconnecting, auto reconnect`); + //new initSocket(); + this.connect(); + } + + if (close.code == 1006) { + // do a single retry + this.eventsub.is_reconnecting = true; + } + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event + this.eventsub.addEventListener('error', (err) => { + console.log(err); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Connection Error`); + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event + this.eventsub.addEventListener('message', (message) => { + //log('Message'); + console.log(this.eventsub.counter, message); + let { data } = message; + data = JSON.parse(data); + + let { metadata, payload } = data; + let { message_id, message_type, message_timestamp } = metadata; + //log(`Recv ${message_id} - ${message_type}`); + + switch (message_type) { + case 'session_welcome': + let { session } = payload; + let { id, keepalive_timeout_seconds } = session; + + log(`${this.eventsub.counter} This is Socket ID ${id}`); + this.eventsub.twitch_websocket_id = id; + + log(`${this.eventsub.counter} This socket declared silence as ${keepalive_timeout_seconds} seconds`); + + if (!this.eventsub.is_reconnecting) { + log('Dirty disconnect or first spawn'); + this.emit('connected', id); + // now you would spawn your topics + } else { + this.emit('reconnected', id); + // no need to spawn topics as carried over + } + + this.silence(keepalive_timeout_seconds); + + break; + case 'session_keepalive': + //log(`Recv KeepAlive - ${message_type}`); + this.emit('session_keepalive'); + this.silence(); + break; + + case 'notification': + console.log('notification', metadata, payload); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Recv notification ${JSON.stringify(payload)}`); + + let { subscription, event } = payload; + let { type } = subscription; + + this.emit('notification', { metadata, payload }); + this.emit(type, { metadata, payload }); + this.silence(); + + break; + + case 'session_reconnect': + this.eventsub.is_reconnecting = true; + + let reconnect_url = payload.session.reconnect_url; + + console.log('Connect to new url', reconnect_url); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Reconnect request ${reconnect_url}`) + + //this.eventsub.close(); + //new initSocket(reconnect_url, true); + this.connect(reconnect_url, true); + + break; + case 'websocket_disconnect': + log(`${this.eventsub.counter} Recv Disconnect`); + console.log('websocket_disconnect', payload); + + break; + + case 'revocation': + log(`${this.eventsub.counter} Recv Topic Revocation`); + console.log('revocation', payload); + this.emit('revocation', { metadata, payload }); + break; + + default: + console.log(`${this.eventsub.counter} unexpected`, metadata, payload); + break; + } + }); + } + + trigger() { + this.eventsub.send('cat'); + } + close() { + this.eventsub.close(); + } + + silenceHandler = false; + silenceTime = 10;// default per docs is 10 so set that as a good default + silence(keepalive_timeout_seconds) { + if (keepalive_timeout_seconds) { + this.silenceTime = keepalive_timeout_seconds; + this.silenceTime++;// add a little window as it's too anal + } + clearTimeout(this.silenceHandler); + this.silenceHandler = setTimeout(() => { + this.emit('session_silenced');// -> self reconnecting + this.close();// close it and let it self loop + }, (this.silenceTime * 1000)); + } + + on(name, listener) { + if (!this._events[name]) { + this._events[name] = []; + } + + this._events[name].push(listener); + } + emit(name, data) { + if (!this._events[name]) { + return; + } + + const fireCallbacks = (callback) => { + callback(data); + }; + + this._events[name].forEach(fireCallbacks); + } +} + +function log(msg) { + if (!document.getElementById('log')) { + return; + } + + let div = document.createElement('div'); + document.getElementById('log').prepend(div); + + let tim = document.createElement('span'); + div.append(tim); + let t = [ + new Date().getHours(), + new Date().getMinutes(), + new Date().getSeconds() + ] + t.forEach((v,i) => { + t[i] = v < 10 ? '0'+v : v; + }); + tim.textContent = t.join(':'); + + let sp = document.createElement('span'); + div.append(sp); + sp.textContent = msg; +} diff --git a/examples/ban_request_manager/index.html b/examples/ban_request_manager/index.html new file mode 100644 index 0000000..7e8ea09 --- /dev/null +++ b/examples/ban_request_manager/index.html @@ -0,0 +1,68 @@ + + + + Ban Request Manager | Twitch API Example + + + + + + + Authorize +
Pending
+ +
+ +
+
+ + +
+ + +
+

Select Action

+
+
Accept
+
Reject
+
No Act
+
+
+ + + + + \ No newline at end of file diff --git a/examples/browse_categories/README.md b/examples/browse_categories/README.md new file mode 100644 index 0000000..571ce86 --- /dev/null +++ b/examples/browse_categories/README.md @@ -0,0 +1,48 @@ +## What is this example + +This is a very rough example of how to build a page which collects the data needed to generate [The Directory](https://www.twitch.tv/directory) + +It uses Implicit auth to obtain a token, but this is just for the [GitHub pages demo](https://barrycarlyon.github.io/twitch_misc/examples/browse_categories/). + +If you are building this yourself and as a "server" application, you can use any kind of token as it's all public data. + +Normally for a server solution (you first wouldn't be doing it client side or with fetch), you'd normally use a [Client Credentaisl/App Access/Server to Server token](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth#oauth-client-credentials-flow) + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/browse_categories/) + +## Reference Documentation + +- [OAuth Implicit Code Flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth#oauth-implicit-code-flow) +- [Get Top Games](https://dev.twitch.tv/docs/api/reference#get-top-games) +- [Get Streams](https://dev.twitch.tv/docs/api/reference#get-streams) - Using GameID's to filter + +## But what about rate limits? + +This example runs in a browser and we are using implicit auth to get a token to use. +As a result we are using frontend JS to make the API calls, and browsers will limit the number of requests made to the same domain (api.twitch.tv in this example), so we can't "hammer" enough to get close to the rate limit. + +But that is something to consider if you are making these calls server side. + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere + +## Screenshot + +![Example](example.png) diff --git a/examples/browse_categories/example.png b/examples/browse_categories/example.png new file mode 100644 index 0000000..72516fb Binary files /dev/null and b/examples/browse_categories/example.png differ diff --git a/examples/browse_categories/index.html b/examples/browse_categories/index.html new file mode 100644 index 0000000..44f2d59 --- /dev/null +++ b/examples/browse_categories/index.html @@ -0,0 +1,169 @@ + + + + Browse Categories | Twitch API Example + + + +

This example first uses Implicit Auth to get a token to use then will return a page similar to The Directorty. Generally calls will be done/cached server side with an App Access Token

+ +

Get the code for this example on Github or just View the source instead

+ +

After authenticating to get a Key, it calls Get Top Games then calls the first few pages of Get Streams for that game to get an approx viewer count.

+ + Authorize +
+ +

Page Loading Status

+ +

Status output as each page of streams for that gameID loads

+ + + + + + + + + + +
GameIDStatus1234...
+ +

The Directory

+
+ + + + diff --git a/examples/calendar/README.md b/examples/calendar/README.md new file mode 100644 index 0000000..86441bc --- /dev/null +++ b/examples/calendar/README.md @@ -0,0 +1,51 @@ +## What is this example + +This is an example for just poking the [Calendar/Schedule](https://dev.twitch.tv/docs/api/reference#get-channel-stream-schedule) part of the Twitch API + +It uses Implicit auth to obtain a token, but this is just for the [GitHub pages demo](https://barrycarlyon.github.io/twitch_misc/examples/calendar/). + +If you are building this yourself and as a "server" application, you can use any kind of token as it's all public data. + +Normally for a server solution (you first wouldn't be doing it client side or with fetch), you'd normally use a [Client Credentaisl/App Access/Server to Server token](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth#oauth-client-credentials-flow) + +This example generates a QR code to the iCalendar URL, the Javascript library [davidshimjs/qrcodejs](https://github.com/davidshimjs/qrcodejs) is used for this. + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/calendar/) + +## Reference Documentation + +- [OAuth Implicit Code Flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth#oauth-implicit-code-flow) +- [Get Channel Stream Schedule](https://dev.twitch.tv/docs/api/reference#get-channel-stream-schedule) +- [Get Channel iCalendar](https://dev.twitch.tv/docs/api/reference#get-channel-icalendar) +- [Get Users](https://dev.twitch.tv/docs/api/reference#get-users) - For Converting `logins` to `user_ids` + +## But what about rate limits? + +This example runs in a browser and we are using implicit auth to get a token to use. +As a result we are using frontend JS to make the API calls, and browsers will limit the number of requests made to the same domain (api.twitch.tv in this example), so we can't "hammer" enough to get close to the rate limit. + +But that is something to consider if you are making these calls server side. + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere + +## Screenshot + +![Example](example.png) diff --git a/examples/calendar/example.png b/examples/calendar/example.png new file mode 100644 index 0000000..e0637c8 Binary files /dev/null and b/examples/calendar/example.png differ diff --git a/examples/calendar/index.html b/examples/calendar/index.html new file mode 100644 index 0000000..dfb433f --- /dev/null +++ b/examples/calendar/index.html @@ -0,0 +1,202 @@ + + + + Calendar Stuff | Twitch API Example + + + + + +

This example will first ask you to login with Implicit Auth to get an API Token to use. It'll then fetch your iCalendar URL, generate a QR Code for that URL and display you schedule segments. You can then also lookup another streamer.

+

Get the code for this example on Github or just View the source instead

+ +

After testing you can Disconnect the "Barry's GitHub Examples" Application on the Connections page

+
+ + + + + + diff --git a/examples/channel_dashboard/README.md b/examples/channel_dashboard/README.md new file mode 100644 index 0000000..97cbd16 --- /dev/null +++ b/examples/channel_dashboard/README.md @@ -0,0 +1,52 @@ +## What is this example + +This is an example of a Twitch Dashboard. + +It uses Implicit auth to obtain a token, generally for a Server Side Solution you'd use a regular Code flow token, and keep the keys on file and refresh those keys. + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/channel_dashboard/) + +## Reference Documentation + + +- [Get Users](https://dev.twitch.tv/docs/api/reference#get-users) - We need the User ID to call other endpoints +- [Get Channel Information](https://dev.twitch.tv/docs/api/reference#get-channel-information) +- [Modify Channel Information](https://dev.twitch.tv/docs/api/reference#modify-channel-information) +- [Get User Installed Extensions](https://dev.twitch.tv/docs/api/reference#get-user-extensions) - See what Extensions are installed to the channel +- [Get User Active Extensions](https://dev.twitch.tv/docs/api/reference#get-user-active-extensions) +- [Update User Extensions](https://dev.twitch.tv/docs/api/reference#update-user-extensions) +- [Search Categories](https://dev.twitch.tv/docs/api/reference#search-categoriess) - To convert a game name to a game ID + +## But what about rate limits? + +This example runs in a browser and we are using implicit auth to get a token to use. +As a result we are using frontend JS to make the API calls, and browsers will limit the number of requests made to the same domain (api.twitch.tv in this example), so we can't "hammer" enough to get close to the rate limit. + +But that is something to consider if you are making these calls server side. + +## Languages + +The language list is likely not quite correct I lifted it from Stack Overflow! + +- https://stackoverflow.com/questions/5580876/navigator-language-list-of-all-languages +- https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere diff --git a/examples/channel_dashboard/index.html b/examples/channel_dashboard/index.html new file mode 100644 index 0000000..7b87f92 --- /dev/null +++ b/examples/channel_dashboard/index.html @@ -0,0 +1,734 @@ + + + + Channel Dashboard | Twitch API Example + + + + + +

This example first uses Implicit Auth to get a token to use then will Various endpoints. + +

We then utilise the following

+ + + +

Get the code for this example on Github or just View the source instead

+ + Authorize +
Login to get Access Token
+ +
+
+
+
+

Channel Information

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Title
Game/Category +
+
+ +
+ +
+
Tags + + + + + + +
+
Add up to 10 tags. Duplicates allow. Each tag can be 25 characters long with no spaces or special characters. +
Delay + +
+ Channel Delay is a function only available to Partners. +
Language
Go Live Notification + +
+ Not Supported via the API +
+ Learn More +
ReRun + +
+ Not Supported via the API +
+ Learn More +
CCLs - Content Classification Labels +
+ Learn More +
Branded Content + + Learn More +
+
+
+

Extensions

+ + + + + + + + + + + + + +
Overlay
Comp 1
Comp 2
+ + + + + + +
Panel 1
Panel 2
Panel 3
+
+
+
+
+ + +
+
+
+ + + + + diff --git a/examples/channel_dashboard/languages.js b/examples/channel_dashboard/languages.js new file mode 100644 index 0000000..213c5e1 --- /dev/null +++ b/examples/channel_dashboard/languages.js @@ -0,0 +1,194 @@ +//https://stackoverflow.com/questions/5580876/navigator-language-list-of-all-languages +let languages = [ + { code: 'ab', name: 'Abkhazian' }, + { code: 'aa', name: 'Afar' }, + { code: 'af', name: 'Afrikaans' }, + { code: 'ak', name: 'Akan' }, + { code: 'sq', name: 'Albanian' }, + { code: 'am', name: 'Amharic' }, + { code: 'ar', name: 'Arabic' }, + { code: 'an', name: 'Aragonese' }, + { code: 'hy', name: 'Armenian' }, + { code: 'as', name: 'Assamese' }, + { code: 'av', name: 'Avaric' }, + { code: 'ae', name: 'Avestan' }, + { code: 'ay', name: 'Aymara' }, + { code: 'az', name: 'Azerbaijani' }, + { code: 'bm', name: 'Bambara' }, + { code: 'ba', name: 'Bashkir' }, + { code: 'eu', name: 'Basque' }, + { code: 'be', name: 'Belarusian' }, + { code: 'bn', name: 'Bengali (Bangla)' }, + { code: 'bh', name: 'Bihari' }, + { code: 'bi', name: 'Bislama' }, + { code: 'bs', name: 'Bosnian' }, + { code: 'br', name: 'Breton' }, + { code: 'bg', name: 'Bulgarian' }, + { code: 'my', name: 'Burmese' }, + { code: 'ca', name: 'Catalan' }, + { code: 'ch', name: 'Chamorro' }, + { code: 'ce', name: 'Chechen' }, + { code: 'ny', name: 'Chichewa, Chewa, Nyanja' }, + { code: 'zh', name: 'Chinese' }, + { code: 'zh-Hans', name: 'Chinese (Simplified)' }, + { code: 'zh-Hant', name: 'Chinese (Traditional)' }, + { code: 'cv', name: 'Chuvash' }, + { code: 'kw', name: 'Cornish' }, + { code: 'co', name: 'Corsican' }, + { code: 'cr', name: 'Cree' }, + { code: 'hr', name: 'Croatian' }, + { code: 'cs', name: 'Czech' }, + { code: 'da', name: 'Danish' }, + { code: 'dv', name: 'Divehi, Dhivehi, Maldivian' }, + { code: 'nl', name: 'Dutch' }, + { code: 'dz', name: 'Dzongkha' }, + { code: 'en', name: 'English' }, + { code: 'eo', name: 'Esperanto' }, + { code: 'et', name: 'Estonian' }, + { code: 'ee', name: 'Ewe' }, + { code: 'fo', name: 'Faroese' }, + { code: 'fj', name: 'Fijian' }, + { code: 'fi', name: 'Finnish' }, + { code: 'fr', name: 'French' }, + { code: 'ff', name: 'Fula, Fulah, Pulaar, Pular' }, + { code: 'gl', name: 'Galician' }, + { code: 'gd', name: 'Gaelic (Scottish)' }, + { code: 'gv', name: 'Gaelic (Manx)' }, + { code: 'ka', name: 'Georgian' }, + { code: 'de', name: 'German' }, + { code: 'el', name: 'Greek' }, + { code: 'kl', name: 'Greenlandic' }, + { code: 'gn', name: 'Guarani' }, + { code: 'gu', name: 'Gujarati' }, + { code: 'ht', name: 'Haitian Creole' }, + { code: 'ha', name: 'Hausa' }, + { code: 'he', name: 'Hebrew' }, + { code: 'hz', name: 'Herero' }, + { code: 'hi', name: 'Hindi' }, + { code: 'ho', name: 'Hiri Motu' }, + { code: 'hu', name: 'Hungarian' }, + { code: 'is', name: 'Icelandic' }, + { code: 'io', name: 'Ido' }, + { code: 'ig', name: 'Igbo' }, + { code: 'id, in', name: 'Indonesian' }, + { code: 'ia', name: 'Interlingua' }, + { code: 'ie', name: 'Interlingue' }, + { code: 'iu', name: 'Inuktitut' }, + { code: 'ik', name: 'Inupiak' }, + { code: 'ga', name: 'Irish' }, + { code: 'it', name: 'Italian' }, + { code: 'ja', name: 'Japanese' }, + { code: 'jv', name: 'Javanese' }, + { code: 'kl', name: 'Kalaallisut, Greenlandic' }, + { code: 'kn', name: 'Kannada' }, + { code: 'kr', name: 'Kanuri' }, + { code: 'ks', name: 'Kashmiri' }, + { code: 'kk', name: 'Kazakh' }, + { code: 'km', name: 'Khmer' }, + { code: 'ki', name: 'Kikuyu' }, + { code: 'rw', name: 'Kinyarwanda (Rwanda)' }, + { code: 'rn', name: 'Kirundi' }, + { code: 'ky', name: 'Kyrgyz' }, + { code: 'kv', name: 'Komi' }, + { code: 'kg', name: 'Kongo' }, + { code: 'ko', name: 'Korean' }, + { code: 'ku', name: 'Kurdish' }, + { code: 'kj', name: 'Kwanyama' }, + { code: 'lo', name: 'Lao' }, + { code: 'la', name: 'Latin' }, + { code: 'lv', name: 'Latvian (Lettish)' }, + { code: 'li', name: 'Limburgish ( Limburger)' }, + { code: 'ln', name: 'Lingala' }, + { code: 'lt', name: 'Lithuanian' }, + { code: 'lu', name: 'Luga-Katanga' }, + { code: 'lg', name: 'Luganda, Ganda' }, + { code: 'lb', name: 'Luxembourgish' }, + { code: 'gv', name: 'Manx' }, + { code: 'mk', name: 'Macedonian' }, + { code: 'mg', name: 'Malagasy' }, + { code: 'ms', name: 'Malay' }, + { code: 'ml', name: 'Malayalam' }, + { code: 'mt', name: 'Maltese' }, + { code: 'mi', name: 'Maori' }, + { code: 'mr', name: 'Marathi' }, + { code: 'mh', name: 'Marshallese' }, + { code: 'mo', name: 'Moldavian' }, + { code: 'mn', name: 'Mongolian' }, + { code: 'na', name: 'Nauru' }, + { code: 'nv', name: 'Navajo' }, + { code: 'ng', name: 'Ndonga' }, + { code: 'nd', name: 'Northern Ndebele' }, + { code: 'ne', name: 'Nepali' }, + { code: 'no', name: 'Norwegian' }, + { code: 'nb', name: 'Norwegian bokmål' }, + { code: 'nn', name: 'Norwegian nynorsk' }, + { code: 'ii', name: 'Nuosu' }, + { code: 'oc', name: 'Occitan' }, + { code: 'oj', name: 'Ojibwe' }, + { code: 'cu', name: 'Old Church Slavonic, Old Bulgarian' }, + { code: 'or', name: 'Oriya' }, + { code: 'om', name: 'Oromo (Afaan Oromo)' }, + { code: 'os', name: 'Ossetian' }, + { code: 'pi', name: 'Pāli' }, + { code: 'ps', name: 'Pashto, Pushto' }, + { code: 'fa', name: 'Persian (Farsi)' }, + { code: 'pl', name: 'Polish' }, + { code: 'pt', name: 'Portuguese' }, + { code: 'pa', name: 'Punjabi (Eastern)' }, + { code: 'qu', name: 'Quechua' }, + { code: 'rm', name: 'Romansh' }, + { code: 'ro', name: 'Romanian' }, + { code: 'ru', name: 'Russian' }, + { code: 'se', name: 'Sami' }, + { code: 'sm', name: 'Samoan' }, + { code: 'sg', name: 'Sango' }, + { code: 'sa', name: 'Sanskrit' }, + { code: 'sr', name: 'Serbian' }, + { code: 'sh', name: 'Serbo-Croatian' }, + { code: 'st', name: 'Sesotho' }, + { code: 'tn', name: 'Setswana' }, + { code: 'sn', name: 'Shona' }, + { code: 'ii', name: 'Sichuan Yi' }, + { code: 'sd', name: 'Sindhi' }, + { code: 'si', name: 'Sinhalese' }, + { code: 'ss', name: 'Siswati' }, + { code: 'sk', name: 'Slovak' }, + { code: 'sl', name: 'Slovenian' }, + { code: 'so', name: 'Somali' }, + { code: 'nr', name: 'Southern Ndebele' }, + { code: 'es', name: 'Spanish' }, + { code: 'su', name: 'Sundanese' }, + { code: 'sw', name: 'Swahili (Kiswahili)' }, + { code: 'ss', name: 'Swati' }, + { code: 'sv', name: 'Swedish' }, + { code: 'tl', name: 'Tagalog' }, + { code: 'ty', name: 'Tahitian' }, + { code: 'tg', name: 'Tajik' }, + { code: 'ta', name: 'Tamil' }, + { code: 'tt', name: 'Tatar' }, + { code: 'te', name: 'Telugu' }, + { code: 'th', name: 'Thai' }, + { code: 'bo', name: 'Tibetan' }, + { code: 'ti', name: 'Tigrinya' }, + { code: 'to', name: 'Tonga' }, + { code: 'ts', name: 'Tsonga' }, + { code: 'tr', name: 'Turkish' }, + { code: 'tk', name: 'Turkmen' }, + { code: 'tw', name: 'Twi' }, + { code: 'ug', name: 'Uyghur' }, + { code: 'uk', name: 'Ukrainian' }, + { code: 'ur', name: 'Urdu' }, + { code: 'uz', name: 'Uzbek' }, + { code: 've', name: 'Venda' }, + { code: 'vi', name: 'Vietnamese' }, + { code: 'vo', name: 'Volapük' }, + { code: 'wa', name: 'Wallon' }, + { code: 'cy', name: 'Welsh' }, + { code: 'wo', name: 'Wolof' }, + { code: 'fy', name: 'Western Frisian' }, + { code: 'xh', name: 'Xhosa' }, + { code: 'yi, ji', name: 'Yiddish' }, + { code: 'yo', name: 'Yoruba' }, + { code: 'za', name: 'Zhuang, Chuang' }, + { code: 'zu', name: 'Zulu' }, +] \ No newline at end of file diff --git a/examples/channel_information/README.md b/examples/channel_information/README.md new file mode 100644 index 0000000..c099468 --- /dev/null +++ b/examples/channel_information/README.md @@ -0,0 +1,48 @@ +## What is this example + +This is a very rough channel information example. + +It uses Implicit auth to obtain a token, but this is just for the [GitHub pages demo](https://barrycarlyon.github.io/twitch_misc/examples/channel_information/). + +If you are building this yourself and as a "server" application, you can use any kind of token as it's all public data. But you would need to have a way to get the users username (or ID) from the user (input field) or prompt the user to login and use a user access token, like this demo does with Implict auth. + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/channel_information/) + +## Reference Documentation + +- [Get Users](https://dev.twitch.tv/docs/api/reference#get-users) +- [Get Channel Information](https://dev.twitch.tv/docs/api/reference#get-channel-information) +- [Get Channel Teams](https://dev.twitch.tv/docs/api/reference#get-channel-teams) +- [Get Streams](https://dev.twitch.tv/docs/api/reference#get-streams) +- [Get Videos](https://dev.twitch.tv/docs/api/reference#get-videos) +- [Get User Active Extensions](https://dev.twitch.tv/docs/api/reference#get-user-active-extensions) +- [Get Channel Emotes](https://dev.twitch.tv/docs/api/reference#get-channel-emotes) +- [Get Cheermotes](https://dev.twitch.tv/docs/api/reference#get-cheermotess) +- [Get Channel Chat Badges](https://dev.twitch.tv/docs/api/reference#get-channel-chat-badges) + +## But what about rate limits? + +This example runs in a browser and we are using implicit auth to get a token to use. +As a result we are using frontend JS to make the API calls, and browsers will limit the number of requests made to the same domain (api.twitch.tv in this example), so we can't "hammer" enough to get close to the rate limit. + +But that is something to consider if you are making these calls server side. + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere diff --git a/examples/channel_information/index.html b/examples/channel_information/index.html new file mode 100644 index 0000000..d6626a9 --- /dev/null +++ b/examples/channel_information/index.html @@ -0,0 +1,343 @@ + + + + Channel Information | Twitch API Example + + + + +

This example first uses Implicit Auth to get a token to use then will Various endpoints. Generally calls will be done/cached server side with an App Access Token

+ +

Get the code for this example on Github or just View the source instead

+ + Authorize + +
+
+ + + +
+
+ +
+
+ + + + diff --git a/examples/channel_points/README.md b/examples/channel_points/README.md new file mode 100644 index 0000000..be73e04 --- /dev/null +++ b/examples/channel_points/README.md @@ -0,0 +1,31 @@ +## What is this example + +A simple Channel Points tool to show rewards on the authenticating channel, create, update and delete those rewards. + +It does two requests when Getting Custom reards, first to get and list _all_ rewards, then a second request to see which rewards the clientID can manage. + +## Notes + +The Rewards API only lets an API caller manage Rewards and Redemptions for Rewards that the Calling Client ID has created. + +So if a streamer creates a reward via the dashboard, you an API consume will be able to see the reward exists and recieve redeems as they occur, but you won't be able to look up historical data, or refund a redeem for example. + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/channel_points/) + +## Reference Documentation + +- [Get Custom Rewards](https://dev.twitch.tv/docs/api/reference#get-custom-reward) +- [Create Custom Rewards](https://dev.twitch.tv/docs/api/reference#create-custom-rewards) +- [Delete Custom Rewards](https://dev.twitch.tv/docs/api/reference#delete-custom-reward) + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere diff --git a/examples/channel_points/channel_points.js b/examples/channel_points/channel_points.js new file mode 100644 index 0000000..cc8c19e --- /dev/null +++ b/examples/channel_points/channel_points.js @@ -0,0 +1,574 @@ +// go populate this with a client_id +var client_id = 'hozgh446gdilj5knsrsxxz8tahr3koz'; +var redirect = window.location.origin + '/twitch_misc/'; +// setup a memory space for the token/userID +var access_token = ''; +var user_id = ''; + +var loading = document.getElementById('loading'); +var output = document.getElementById('output'); + +document.getElementById('authorize').setAttribute('href', 'https://id.twitch.tv/oauth2/authorize?client_id=' + client_id + '&redirect_uri=' + encodeURIComponent(redirect) + '&response_type=token&scope=channel:read:redemptions+channel:manage:redemptions') + +function provideAccessToken(token) { + fetch( + 'https://id.twitch.tv/oauth2/validate', + { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + } + ) + .then(resp => resp.json()) + .then(resp => { + if (resp.client_id) { + client_id = resp.client_id; + processToken(token); + + return; + } + + document.getElementById('loading').textContent = 'The token seems invalid'; + }) + .catch(err => { + console.log(err); + document.getElementById('loading').textContent = 'An Error Occured loading Validating Token: error'; + }); +} +provide_access_token.addEventListener('submit', (e) => { + e.preventDefault(); + provideAccessToken(input_access_token.value); +}); + +function processToken(token) { + access_token = token; + + message('Got Token'); + message('Loading User from Token'); + + fetch( + 'https://api.twitch.tv/helix/users', + { + "headers": { + "Client-ID": client_id, + "Authorization": `Bearer ${access_token}` + } + } + ) + .then(resp => resp.json()) + .then(resp => { + if (resp.data.length == 1) { + message('Got User ID'); + + user_id = resp.data[0].id; + + getRewards(); + // create eventsub for real time updates? + eventsub(); + } else { + document.getElementById('loading').textContent = 'An Error Occured loading Profile: not returned'; + } + }) + .catch(err => { + console.log(err); + document.getElementById('loading').textContent = 'An Error Occured loading Profile: error'; + }); +} + +function message(words) { + let p = document.createElement('p'); + document.getElementById('loading').prepend(p); + p.textContent = words; +} + +function getRewards() { + message('Loading Rewards'); + + output.textContent = ''; + + let params = [ + [ 'broadcaster_id', user_id ], + [ 'first', 100 ] + ]; + + let url = new URL('https://api.twitch.tv/helix/channel_points/custom_rewards'); + url.search = new URLSearchParams(params).toString(); + + fetch( + url, + { + "method": "GET", + "headers": { + "Client-ID": client_id, + "Authorization": `Bearer ${access_token}` + } + } + ) + .then(resp => resp.json()) + .then(resp => { + message(`Obtained ${resp.data.length} rewards`); + if (resp.data.length >= 1) { + + resp.data.forEach(reward => { + let tr = document.createElement('tr'); + output.append(tr); + + tr.setAttribute('id', reward.id); + tr.style.backgroundColor = reward.background_color; + + cell('Cannot', tr); + cell(reward.id, tr); + + let itd = cell('', tr); + if (reward.image && reward.image.url_1x) { + let iti = document.createElement('img'); + itd.append(iti); + iti.setAttribute('src', reward.image.url_1x); + } + + let titleCell = cell('', tr); + var d = document.createElement('div'); + titleCell.append(d); + var i = document.createElement('input'); + d.append(i); + i.value = reward.title; + i.setAttribute('id', `field_${reward.id}_title`); + var u = document.createElement('input'); + d.append(u); + u.setAttribute('type', 'button'); + u.setAttribute('data-linked', `field_${reward.id}_title`); + u.setAttribute('data-update', 'title'); + u.setAttribute('data-id', reward.id); + u.value = 'U'; + cell(reward.cost, tr); + + var td = tr.insertCell(); + let enabler = colorCell((reward.is_enabled ? 'Enabled' : 'Disabled'), td, reward.is_enabled); + enabler.setAttribute('data-toggle', 'is_enabled'); + enabler.setAttribute('data-id', reward.id); + var td = tr.insertCell(); + let pauser = colorCell((reward.is_paused ? 'Paused' : 'Running'), td, !reward.is_paused); + pauser.setAttribute('data-toggle', 'is_paused'); + pauser.setAttribute('data-id', reward.id); + cell((reward.should_redemptions_skip_request_queue ? 'Skips' : 'Need Mod'), tr); + let promptCell = cell('', tr); + var d = document.createElement('div'); + promptCell.append(d); + var i = document.createElement('input'); + d.append(i); + i.value = reward.prompt; + i.setAttribute('id', `field_${reward.id}_prompt`); + var u = document.createElement('input'); + d.append(u); + u.setAttribute('type', 'button'); + u.setAttribute('data-linked', `field_${reward.id}_prompt`); + u.setAttribute('data-update', 'prompt'); + u.setAttribute('data-id', reward.id); + u.value = 'U'; + var td = tr.insertCell(); + colorCell((reward.is_user_input_required ? 'IsReq' : ''), td, reward.is_user_input_required); + + let del = cell('x', tr); + del.classList.add('delete_reward'); + del.setAttribute('data-id', reward.id); + }); + + getRewardsManage(); + } else { + message('An Error Occured loading Rewards: no rewards'); + } + }) + .catch(err => { + console.log(err); + message('An Error Occured loading Rewards: error'); + }); +} + +function getRewardsManage() { + message('Loading Rewards Can Manage'); + + let params = [ + [ 'broadcaster_id', user_id ], + [ 'first', 100 ], + [ 'only_manageable_rewards', 'true' ] + ]; + + let url = new URL('https://api.twitch.tv/helix/channel_points/custom_rewards'); + url.search = new URLSearchParams(params).toString(); + + fetch( + url, + { + "method": "GET", + "headers": { + "Client-ID": client_id, + "Authorization": `Bearer ${access_token}` + } + } + ) + .then(resp => resp.json()) + .then(resp => { + message(`Obtained ${resp.data.length} manageable rewards`); + + resp.data.forEach((reward) => { + let { id } = reward; + let el = document.getElementById(id); + if (el) { + el.querySelector('td').textContent = 'Can'; + el.querySelector('td').style.backgroundColor = 'green'; + } + }); + }) + .catch(err => { + console.log(err); + message('An Error Occured loading Manageable Rewards: error'); + }); +} + +function colorCell(text, td, value) { + td.textContent = text; + if (value) { + td.style.backgroundColor = 'green'; + } else { + td.style.backgroundColor = 'red'; + } + return td; +} + +function cell(value, row) { + let td = document.createElement('td'); + row.append(td); + td.textContent = value; + return td; +} + + +document.getElementById('reward_create_form').addEventListener('submit', (e) => { + e.preventDefault(); + + let payload = { + title: reward_title.value, + cost: reward_cost.value, + + prompt: reward_prompt.value, + is_enabled: reward_is_enabled.checked, + + background_color: reward_background_color.value, + + is_user_input_required: reward_is_user_input_required.checked, + + is_max_per_stream_enabled: is_max_per_stream_enabled.checked, + is_max_per_user_per_stream_enabled: is_max_per_user_per_stream_enabled.checked, + is_global_cooldown_enabled: is_global_cooldown_enabled.checked, + + should_redemptions_skip_request_queue: reward_should_redemptions_skip_request_queue.checked + } + //max_per_stream + //max_per_user_per_stream + //global_cooldown_seconds + if (payload.is_max_per_stream_enabled) { + payload.max_per_stream = max_per_stream.value; + } + if (payload.is_max_per_user_per_stream_enabled) { + payload.max_per_user_per_stream = max_per_user_per_stream.value; + } + if (payload.is_global_cooldown_enabled) { + payload.global_cooldown_seconds = global_cooldown_seconds.value; + } + + for (key in payload) { + if (payload[key] === '') { + delete payload[key]; + } + } + + console.log(payload);//return; + + let url = new URL('https://api.twitch.tv/helix/channel_points/custom_rewards'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', user_id ] + ]).toString(); + + message('Attempting to create a reward'); + fetch( + url, + { + "method": "POST", + "headers": { + "Client-ID": client_id, + "Authorization": `Bearer ${access_token}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + } + ) + .then(r => r.json().then(data => ({ status: r.status, body: data }))) + .then(resp => { + message(`Reward Create Result: ${resp.status}`); + if (resp.status != 204) { + message(`Response: ${resp.body.message}`); + } + getRewards(); + }) + .catch(err => { + console.log(err); + message('An Error Occured creating a reward: error'); + }); +}); + +function deleteReward(id) { + message(`Attempt to delete reward ${id}`); + let url = new URL('https://api.twitch.tv/helix/channel_points/custom_rewards'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', user_id ], + [ 'id', id ] + ]); + + fetch( + url, + { + "method": "DELETE", + "headers": { + "Client-ID": client_id, + "Authorization": `Bearer ${access_token}`, + } + } + ) + .then(async resp => { + message(`Reward Delete Result: ${resp.status}`); + if (resp.status == 204) { + getRewards(); + } else { + message(`Reward Delete Result: ${await resp.text()}`); + } + }) + .catch(err => { + console.log(err); + message('An Error Occured deleting a reward: error'); + }); +} + +// ui +is_max_per_stream_enabled.addEventListener('change', (e) => { + if (e.target.checked) { + max_per_stream.removeAttribute('disabled'); + } else { + max_per_stream.setAttribute('disabled', 'disabled'); + } +}); +max_per_stream.setAttribute('disabled', 'disabled'); + +is_max_per_user_per_stream_enabled.addEventListener('change', (e) => { + if (e.target.checked) { + max_per_user_per_stream.removeAttribute('disabled'); + } else { + max_per_user_per_stream.setAttribute('disabled', 'disabled'); + } +}); +max_per_user_per_stream.setAttribute('disabled', 'disabled'); + +is_global_cooldown_enabled.addEventListener('change', (e) => { + if (e.target.checked) { + global_cooldown_seconds.removeAttribute('disabled'); + } else { + global_cooldown_seconds.setAttribute('disabled', 'disabled'); + } +}); +global_cooldown_seconds.setAttribute('disabled', 'disabled'); + +output.addEventListener('click', (e) => { + let rewardID = e.target.getAttribute('data-id'); + + if (e.target.classList.contains('delete_reward')) { + deleteReward(e.target.getAttribute('data-id')); + } else if (e.target.getAttribute('data-toggle')) { + // we have a jerb + let toggle = e.target.getAttribute('data-toggle'); + + // build a patch + togglePatcher(rewardID, toggle); + } else if (e.target.getAttribute('data-update')) { + let pl = {}; + pl[e.target.getAttribute('data-update')] = document.getElementById(e.target.getAttribute('data-linked')).value; + patcher(rewardID, pl); + } +}); +async function togglePatcher(id, field) { + // get current value + let url = new URL('https://api.twitch.tv/helix/channel_points/custom_rewards'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', user_id ], + [ 'id', id ] + ]); + + let currentReq = await fetch( + url, + { + "method": 'GET', + "headers": { + "Client-ID": client_id, + "Authorization": `Bearer ${access_token}`, + } + } + ); + if (currentReq.status != 200) { + message(`Reward Update GET Error: ${await currentReq.text()}`); + // balls + return; + } + let currentData = await currentReq.json(); + // find it + let { data } = currentData; + if (data.length != 1) { + message(`Reward Update Error: Failed to get current value`); + return; + } + // find the field + let currentValue = data[0][field]; + // invert it + let pl = {}; + pl[field] = !currentValue; + console.log('Patching', pl); + // and patch it + patcher(id, pl); +} + +async function patcher(id, pl) { + let url = new URL('https://api.twitch.tv/helix/channel_points/custom_rewards'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', user_id ], + [ 'id', id ] + ]); + + let patchReq = await fetch( + url, + { + "method": 'PATCH', + "headers": { + "Client-ID": client_id, + "Authorization": `Bearer ${access_token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(pl) + } + ); + if (patchReq.status != 200) { + // balls + message(`Reward Update Error: ${await patchReq.text()}`); + } + getRewards(); +} + +function eventsub() { + let socket_space = new initSocket(true); + socket_space.on('connected', (id) => { + message(`Connected to WebSocket with ${id}`); + requestHooks(id, user_id); + }); + socket_space.on('session_keepalive', () => { + console.log('keepalive', new Date()); + }); + + socket_space.on('channel.channel_points_custom_reward.update', ({ metadata, payload }) => { + let { event } = payload; + + let { id } = event; + message(`Process a channel.channel_points_custom_reward.update on ${id}`); + + let { image, title, cost } = event; + + let { is_enabled, is_paused } = event; + let { should_redemptions_skip_request_queue } = event; + + let { prompt, is_user_input_required } = event; + + let row = document.getElementById(id); + if (!row) { + // not found + return; + } + + let cells = row.querySelectorAll('td'); + + // 2 is the image + //cells[2].IMAGE + cells[2].textContent = ''; + if (image && image.url_1x) { + let iti = document.createElement('img'); + cells[2].append(iti); + iti.setAttribute('src', image.url_1x); + } + + // 3 is the title + let inp = document.getElementById(`field_${id}_title`); + if (inp) { + inp.vlaue = title; + } + // 4 is the price + cells[4].textContent = cost; + // 5 is enabled 6 is paused + colorCell((is_enabled ? 'Enabled' : 'Disabled'), cells[5], is_enabled); + colorCell((is_paused ? 'Paused' : 'Running'), cells[6], !is_paused); + // 7 skip + cells[7].textContent = (should_redemptions_skip_request_queue ? 'Skips' : 'Need Mod'); + // 8 prompt + //cells[8].textContent = prompt; + let promptCell = document.getElementById(`field_${id}_prompt`); + promptCell.value = prompt; + // 9 user input + colorCell((is_user_input_required ? 'IsReq' : ''), cells[9], is_user_input_required); + + }); +} + + +function requestHooks(session_id, user_id) { + message('Requesting Topics'); + let topics = { + 'channel.channel_points_custom_reward.add': { version: 1, condition: { broadcaster_user_id: user_id } }, + 'channel.channel_points_custom_reward.update': { version: 1, condition: { broadcaster_user_id: user_id } }, + 'channel.channel_points_custom_reward.remove': { version: 1, condition: { broadcaster_user_id: user_id } }, + } + + message(`Spawn Topics for ${user_id}`); + + for (let type in topics) { + message(`Attempt create ${type} - ${user_id}`); + let { version, condition } = topics[type]; + + fetch( + 'https://api.twitch.tv/helix/eventsub/subscriptions', + { + "method": "POST", + "headers": { + "Client-ID": client_id, + "Authorization": "Bearer " + access_token, + 'Content-Type': 'application/json' + }, + "body": JSON.stringify({ + type, + version, + condition, + transport: { + method: "websocket", + session_id + } + }) + } + ) + .then(resp => resp.json()) + .then(resp => { + if (resp.error) { + message(`Error with eventsub Call ${type} Call: ${resp.message ? resp.message : ''}`); + } else { + message(`Eventsub Created ${type}`); + } + }) + .catch(err => { + console.log(err); + message(`Error with eventsub Call ${type} Call: ${err.message ? err.message : ''}`); + }); + } +} \ No newline at end of file diff --git a/examples/channel_points/eventsub.js b/examples/channel_points/eventsub.js new file mode 100644 index 0000000..2325c41 --- /dev/null +++ b/examples/channel_points/eventsub.js @@ -0,0 +1,207 @@ +class initSocket { + counter = 0 + closeCodes = { + 4000: 'Internal Server Error', + 4001: 'Client sent inbound traffic', + 4002: 'Client failed ping-pong', + 4003: 'Connection unused', + 4004: 'Reconnect grace time expired', + 4005: 'Network Timeout', + 4006: 'Network error', + 4007: 'Invalid Reconnect' + } + + constructor(connect) { + this._events = {}; + + if (connect) { + this.connect(); + } + } + + connect(url, is_reconnect) { + this.eventsub = {}; + this.counter++; + + url = url ? url : 'wss://eventsub.wss.twitch.tv/ws'; + is_reconnect = is_reconnect ? is_reconnect : false; + + log(`Connecting to ${url}|${is_reconnect}`); + this.eventsub = new WebSocket(url); + this.eventsub.is_reconnecting = is_reconnect; + this.eventsub.counter = this.counter; + + this.eventsub.addEventListener('open', () => { + log(`Opened Connection to Twitch`); + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event + // https://github.com/Luka967/websocket-close-codes + this.eventsub.addEventListener('close', (close) => { + console.log('EventSub close', close, this.eventsub); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Connection Closed: ${close.code} Reason - ${this.closeCodes[close.code]}`); + + if (!this.eventsub.is_reconnecting) { + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Is not reconnecting, auto reconnect`); + //new initSocket(); + this.connect(); + } + + if (close.code == 1006) { + // do a single retry + this.eventsub.is_reconnecting = true; + } + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event + this.eventsub.addEventListener('error', (err) => { + console.log(err); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Connection Error`); + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event + this.eventsub.addEventListener('message', (message) => { + //log('Message'); + console.log(this.eventsub.counter, message); + let { data } = message; + data = JSON.parse(data); + + let { metadata, payload } = data; + let { message_id, message_type, message_timestamp } = metadata; + //log(`Recv ${message_id} - ${message_type}`); + + switch (message_type) { + case 'session_welcome': + let { session } = payload; + let { id, keepalive_timeout_seconds } = session; + + log(`${this.eventsub.counter} This is Socket ID ${id}`); + this.eventsub.twitch_websocket_id = id; + + log(`${this.eventsub.counter} This socket declared silence as ${keepalive_timeout_seconds} seconds`); + + if (!this.eventsub.is_reconnecting) { + log('Dirty disconnect or first spawn'); + this.emit('connected', id); + // now you would spawn your topics + } else { + this.emit('reconnected', id); + // no need to spawn topics as carried over + } + + this.silence(keepalive_timeout_seconds); + + break; + case 'session_keepalive': + //log(`Recv KeepAlive - ${message_type}`); + this.emit('session_keepalive'); + this.silence(); + break; + + case 'notification': + console.log('notification', metadata, payload); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Recv notification ${JSON.stringify(payload)}`); + + let { subscription, event } = payload; + let { type } = subscription; + + this.emit('notification', { metadata, payload }); + this.emit(type, { metadata, payload }); + this.silence(); + + break; + + case 'session_reconnect': + this.eventsub.is_reconnecting = true; + + let reconnect_url = payload.session.reconnect_url; + + console.log('Connect to new url', reconnect_url); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Reconnect request ${reconnect_url}`) + + //this.eventsub.close(); + //new initSocket(reconnect_url, true); + this.connect(reconnect_url, true); + + break; + case 'websocket_disconnect': + log(`${this.eventsub.counter} Recv Disconnect`); + console.log('websocket_disconnect', payload); + + break; + + case 'revocation': + log(`${this.eventsub.counter} Recv Topic Revocation`); + console.log('revocation', payload); + this.emit('revocation', { metadata, payload }); + break; + + default: + console.log(`${this.eventsub.counter} unexpected`, metadata, payload); + break; + } + }); + } + + trigger() { + this.eventsub.send('cat'); + } + close() { + this.eventsub.close(); + } + + silenceHandler = false; + silenceTime = 10;// default per docs is 10 so set that as a good default + silence(keepalive_timeout_seconds) { + if (keepalive_timeout_seconds) { + this.silenceTime = keepalive_timeout_seconds; + this.silenceTime++;// add a little window as it's too anal + } + clearTimeout(this.silenceHandler); + this.silenceHandler = setTimeout(() => { + this.emit('session_silenced');// -> self reconnecting + this.close();// close it and let it self loop + }, (this.silenceTime * 1000)); + } + + on(name, listener) { + if (!this._events[name]) { + this._events[name] = []; + } + + this._events[name].push(listener); + } + emit(name, data) { + if (!this._events[name]) { + return; + } + + const fireCallbacks = (callback) => { + callback(data); + }; + + this._events[name].forEach(fireCallbacks); + } +} + +function log(msg) { + if (!document.getElementById('log')) { + return; + } + + let div = document.createElement('div'); + document.getElementById('log').prepend(div); + + let tim = document.createElement('span'); + div.append(tim); + let t = [ + new Date().getHours(), + new Date().getMinutes(), + new Date().getSeconds() + ] + t.forEach((v,i) => { + t[i] = v < 10 ? '0'+v : v; + }); + tim.textContent = t.join(':'); + + let sp = document.createElement('span'); + div.append(sp); + sp.textContent = msg; +} diff --git a/examples/channel_points/index.html b/examples/channel_points/index.html new file mode 100644 index 0000000..932870e --- /dev/null +++ b/examples/channel_points/index.html @@ -0,0 +1,152 @@ + + + + Channel Points | Twitch API Example + + + + + +

This example first uses Implicit Auth to get a token to use then will Various endpoints and permission to read YOUR data

+ +

Get the code for this example on Github or just View the source instead

+ +

Generates a page that shows simiar stuff to Rewards Dashboard

+ + Authorize + or +
+
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + +
Can ControlReward IDCustom ImageReward TitleCostVisiblePausedSkip QueuePromptUser Input Req.X
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ +
+ +
+
+
+ + + + diff --git a/examples/chatters/README.md b/examples/chatters/README.md new file mode 100644 index 0000000..08710c8 --- /dev/null +++ b/examples/chatters/README.md @@ -0,0 +1,38 @@ +## What is this example + +A simple example to Demo the Chatters Endpoint + +It uses Implicit auth to obtain a token, but this is just for the [GitHub pages demo](https://barrycarlyon.github.io/twitch_misc/examples/chatters/). + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/chatters/) + +## Reference Documentation + +- [Get Chatters](https://dev.twitch.tv/docs/api/reference/#get-chatters) + +## But what about rate limits? + +This example runs in a browser and we are using implicit auth to get a token to use. +As a result we are using frontend JS to make the API calls, and browsers will limit the number of requests made to the same domain (api.twitch.tv in this example), so we can't "hammer" enough to get close to the rate limit. + +But that is something to consider if you are making these calls server side. + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere diff --git a/examples/chatters/index.html b/examples/chatters/index.html new file mode 100644 index 0000000..af507b2 --- /dev/null +++ b/examples/chatters/index.html @@ -0,0 +1,91 @@ + + + + Chatters Example | Twitch API Example + + + +

This example first uses Implicit Auth to get a token to use then will retrun a page similar to The Directorty.

+ +

After authenticating to get a Key, it calls Get Chatters.

+ + Authorize +
+ +
+
+ + + + + + + + + + +
UserIDUsernameDisplay
+
+ + + + diff --git a/examples/clips_navigator/README.md b/examples/clips_navigator/README.md new file mode 100644 index 0000000..3ebc855 --- /dev/null +++ b/examples/clips_navigator/README.md @@ -0,0 +1,34 @@ +## What is this example + +This is a very rough example of how to build a page to fetch and display clips between a user specified time frame. + +It uses Implicit auth to obtain a token, but this is just for the [GitHub pages demo](https://barrycarlyon.github.io/twitch_misc/examples/clips_navigator/). + + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/clips_navigator/) + +## Reference Documentation + +- [OAuth Implicit Code Flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth#oauth-implicit-code-flow) +- [Get Clips](https://dev.twitch.tv/docs/api/reference#get-clips) +- [Clips Embedding](https://dev.twitch.tv/docs/embed/video-and-clips#non-interactive-iframes-for-clips) + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere \ No newline at end of file diff --git a/examples/clips_navigator/index.html b/examples/clips_navigator/index.html new file mode 100644 index 0000000..50a3a0c --- /dev/null +++ b/examples/clips_navigator/index.html @@ -0,0 +1,306 @@ + + + + Clips Naivgator | Twitch API Example + + + + + +

This example first uses Implicit Auth. + +

Get the code for this example on Github or just View the source instead

+ +

After authenticating to get a Key, it calls

+ + + Authorize +
+ +
+
+ + + + + + + +
+
+ +
+ + + + + + + + + + + +
IDTitleCreatedAtViewsURL
+ + + + diff --git a/examples/extension_config/README.md b/examples/extension_config/README.md new file mode 100644 index 0000000..8deee6f --- /dev/null +++ b/examples/extension_config/README.md @@ -0,0 +1,20 @@ +## What is this example + +This example is a simple web based tool to read/write from the Extension config service. + +It sorta replaces the Config Service tool that is in the Developer Rig. Which calls the deprecated Extension APIs. + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/extension_config/) + +## Reference Documentation + +- [Get Extension Configuration Segment](https://dev.twitch.tv/docs/api/reference#get-extension-configuration-segment) +- [Set Extension Configuration Segment](https://dev.twitch.tv/docs/api/reference#set-extension-configuration-segment) + +## Desktop Alternative + +Checkout: https://github.com/BarryCarlyon/twitch_extension_tools diff --git a/examples/extension_config/index.html b/examples/extension_config/index.html new file mode 100644 index 0000000..15a3d2d --- /dev/null +++ b/examples/extension_config/index.html @@ -0,0 +1,299 @@ + + + + Extension Config Tool | Twitch API Example + + + + + + + + + +

This tool serves as a Replacement for the Config Service control options in the Rig. As the Dev Rig likely won't be upgraded and calls the deprecated API's

+ +

This tools lets you read/write to the Twitch Extensions Configuration Service. For a desktop version that will rememebr your Extension Settings checkout BarryCarlyons' Twitch Extension Tools

+ +

Reference Documentation

+ + +

You will need to copy/paste your Credentais in, this tool doesn't collect or store them in any way. and runs as Pure client side javascript

+ +

Version is entirely optional and not a thing I generally use. Included for completeness

+

The tool will attempt to JSON parse/stringify as needed, but the config service can store "flat strings" it doesn't have to be JSON Parsable

+

There is a 5kb limit per segement!

+ +
+
+ Settings +
+ + +
+
+ + +
+ +
+ + +
+
+
+ Interact + +
+ + +
+ +
+ + +
+
+
+ Data + +
+ + +
+
+ + +
+
+ + +
+
+
+ Controls + + +
+
+ + + + diff --git a/examples/guest_star/controller/eventsub.js b/examples/guest_star/controller/eventsub.js new file mode 100644 index 0000000..570bc0f --- /dev/null +++ b/examples/guest_star/controller/eventsub.js @@ -0,0 +1,207 @@ +class initSocket { + counter = 0 + closeCodes = { + 4000: 'Internal Server Error', + 4001: 'Client sent inbound traffic', + 4002: 'Client failed ping-pong', + 4003: 'Connection unused', + 4004: 'Reconnect grace time expired', + 4005: 'Network Timeout', + 4006: 'Network error', + 4007: 'Invalid Reconnect' + } + + constructor(connect) { + this._events = {}; + + if (connect) { + this.connect(); + } + } + + connect(url, is_reconnect) { + this.eventsub = {}; + this.counter++; + + url = url ? url : 'wss://eventsub.wss.twitch.tv/ws'; + is_reconnect = is_reconnect ? is_reconnect : false; + + log(`Connecting to ${url}|${is_reconnect}`); + this.eventsub = new WebSocket(url); + this.eventsub.is_reconnecting = is_reconnect; + this.eventsub.counter = this.counter; + + this.eventsub.addEventListener('open', () => { + log(`Opened Connection to Twitch`); + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event + // https://github.com/Luka967/websocket-close-codes + this.eventsub.addEventListener('close', (close) => { + console.log('EventSub close', close, this.eventsub); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Connection Closed: ${close.code} Reason - ${this.closeCodes[close.code]}`); + + if (!this.eventsub.is_reconnecting) { + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Is not reconnecting, auto reconnect`); + //new initSocket(); + this.connect(); + } + + if (close.code == 1006) { + // do a single retry + this.eventsub.is_reconnecting = true; + } + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event + this.eventsub.addEventListener('error', (err) => { + console.log(err); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Connection Error`); + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event + this.eventsub.addEventListener('message', (message) => { + //log('Message'); + console.log(this.eventsub.counter, message); + let { data } = message; + data = JSON.parse(data); + + let { metadata, payload } = data; + let { message_id, message_type, message_timestamp } = metadata; + //log(`Recv ${message_id} - ${message_type}`); + + switch (message_type) { + case 'session_welcome': + let { session } = payload; + let { id, keepalive_timeout_seconds } = session; + + log(`${this.eventsub.counter} This is Socket ID ${id}`); + this.eventsub.twitch_websocket_id = id; + + log(`${this.eventsub.counter} This socket declared silence as ${keepalive_timeout_seconds} seconds`); + + if (!this.eventsub.is_reconnecting) { + log('Dirty disconnect or first spawn'); + this.emit('connected', id); + // now you would spawn your topics + } else { + this.emit('reconnected', id); + // no need to spawn topics as carried over + } + + this.silence(keepalive_timeout_seconds); + + break; + case 'session_keepalive': + //log(`Recv KeepAlive - ${message_type}`); + this.emit('session_keepalive'); + this.silence(); + break; + + case 'notification': + console.log('notification', metadata, payload); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Recv notification ${JSON.stringify(payload)}`); + + let { subscription, event } = payload; + let { type } = subscription; + + this.emit('notification', { metadata, payload }); + this.emit(type, { metadata, payload }); + this.silence(); + + break; + + case 'session_reconnect': + this.eventsub.is_reconnecting = true; + + let reconnect_url = payload.session.reconnect_url; + + console.log('Connect to new url', reconnect_url); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Reconnect request ${reconnect_url}`) + + //this.eventsub.close(); + //new initSocket(reconnect_url, true); + this.connect(reconnect_url, true); + + break; + case 'websocket_disconnect': + log(`${this.eventsub.counter} Recv Disconnect`); + console.log('websocket_disconnect', payload); + + break; + + case 'revocation': + log(`${this.eventsub.counter} Recv Topic Revocation`); + console.log('revocation', payload); + this.emit('revocation', { metadata, payload }); + break; + + default: + console.log(`${this.eventsub.counter} unexpected`, metadata, payload); + break; + } + }); + } + + trigger() { + this.eventsub.send('cat'); + } + close() { + this.eventsub.close(); + } + + silenceHandler = false; + silenceTime = 10;// default per docs is 10 so set that as a good default + silence(keepalive_timeout_seconds) { + if (keepalive_timeout_seconds) { + this.silenceTime = keepalive_timeout_seconds; + this.silenceTime++;// add a little window as it's too anal + } + clearTimeout(this.silenceHandler); + this.silenceHandler = setTimeout(() => { + this.emit('session_silenced');// -> self reconnecting + this.close();// close it and let it self loop + }, (this.silenceTime * 1000)); + } + + on(name, listener) { + if (!this._events[name]) { + this._events[name] = []; + } + + this._events[name].push(listener); + } + emit(name, data) { + if (!this._events[name]) { + return; + } + + const fireCallbacks = (callback) => { + callback(data); + }; + + this._events[name].forEach(fireCallbacks); + } +} + +function log(msg) { + if (!document.getElementById('log')) { + return; + } + + let div = document.createElement('div'); + document.getElementById('log').prepend(div); + + let tim = document.createElement('span'); + div.append(tim); + let t = [ + new Date().getHours(), + new Date().getMinutes(), + new Date().getSeconds() + ] + t.forEach((v,i) => { + t[i] = v < 10 ? '0'+v : v; + }); + tim.textContent = t.join(':'); + + let sp = document.createElement('span'); + div.append(sp); + sp.textContent = msg; +} diff --git a/examples/guest_star/controller/gueststar.js b/examples/guest_star/controller/gueststar.js new file mode 100644 index 0000000..d2123b7 --- /dev/null +++ b/examples/guest_star/controller/gueststar.js @@ -0,0 +1,1070 @@ +// These are set for the GitHub Pages Example +// Substitute as needed +var client_id = 'hozgh446gdilj5knsrsxxz8tahr3koz'; +var redirect = `https://${window.location.host}/twitch_misc/`; +var access_token = ''; + +let commonHeaders = {}; +let whoami = {}; +let broadcaster_id = ''; +let moderator_id = ''; +let active_session_id = ''; +let active_session_slot_count = 0; + +let user_name_cache = {}; + +document.getElementById('authorize').setAttribute('href', 'https://id.twitch.tv/oauth2/authorize?client_id=' + client_id + '&redirect_uri=' + encodeURIComponent(redirect) + '&response_type=token&scope=channel:manage:guest_star+moderator:manage:guest_star'); + +async function getUserData(username) { + let url = new URL('https://api.twitch.tv/helix/users'); + if (username) { + url.search = new URLSearchParams([ + [ 'login', username ] + ]); + } + let resp = await fetch( + url, + { + method: 'GET', + headers: { + ...commonHeaders + } + } + ); + if (resp.status != 200) { + status_bar.textContent = 'Failed to user lookup: ' + await resp.text(); + return; + } + let data = await resp.json(); + if (data.data.length == 1) { + return data.data[0]; + } + status_bar.textContent = 'Failed to find user'; +} + +async function processToken(token) { + access_token = token; + + commonHeaders = { + 'Accept': 'application/json', + 'Client-ID': client_id, + 'Authorization': `Bearer ${access_token}` + } + + status_bar.classList.add('show'); + status_bar.textContent = 'Loading'; + + let moderator = await getUserData(); + moderator_id = moderator.id; + + whoami = moderator; + let els = document.querySelectorAll('.whoami'); + els.forEach(el => { + el.textContent = `You are: ${moderator.display_name}`; + }) + + selector.style.display = 'block'; +} + + +async function moderateChannel(e) { + e.preventDefault(); + + status_bar.textContent = `Loking up ${moderate_channel_name.value}`; + let user = await getUserData(moderate_channel_name.value); + + broadcaster_id = user.id; + let els = document.querySelectorAll('.target_channel'); + els.forEach(el => { + el.textContent = `${user.display_name}`; + }) + + initGuestStar(); +} +moderate_channel_form.addEventListener('submit', moderateChannel); +moderate_channel.addEventListener('click', moderateChannel); +control_channel.addEventListener('click', (e) => { + broadcaster_id = moderator_id; + + let els = document.querySelectorAll('.target_channel'); + els.forEach(el => { + el.textContent = `${whoami.display_name}`; + }) + + initGuestStar(); +}); + +// control functions +controls.addEventListener('click', async (e) => { + let func = e.target.getAttribute('data-function'); + if (!func) { + return; + } + e.preventDefault(); + + switch (func) { + case 'start': + var url = new URL('https://api.twitch.tv/helix/guest_star/session'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', broadcaster_id ] + ]); + + var req = await fetch( + url, + { + method: 'POST', + headers: { + ...commonHeaders + } + } + ); + if (req.status != 200) { + status_bar.textContent = 'Failed to Start Guest Star Session: ' + await req.text(); + return; + } + + var resp = await req.json(); + if (resp.data.length != 1) { + status_bar.textContent = 'Not one Guest Star Session'; + return; + } + //var { id } = resp.data[0]; + //active_session_id = id; + + status_bar.textContent = 'Started a Guest Star Session'; + console.log('Skipping the load'); + //loadSession(); + return; + case 'stop': + var url = new URL('https://api.twitch.tv/helix/guest_star/session'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', broadcaster_id ], + [ 'session_id', active_session_id ] + ]); + + var req = await fetch( + url, + { + method: 'DELETE', + headers: { + ...commonHeaders + } + } + ); + if (req.status != 200) { + status_bar.textContent = 'Failed to Stop Guest Star Session: ' + await req.text(); + return; + } + status_bar.textContent = 'Stopped Guest Star Session'; + //loadSession(); + break; + + case 'refresh_invites': + refreshInvites(); + break; + case 'slot_refresh': + loadSession(); + break; + case 'delete_invite': + var url = new URL('https://api.twitch.tv/helix/guest_star/invites'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', broadcaster_id ], + [ 'moderator_id', moderator_id ], + [ 'session_id', active_session_id ], + [ 'guest_id', e.target.getAttribute('data-user-id') ] + ]); + + var req = await fetch( + url, + { + method: 'DELETE', + headers: { + ...commonHeaders + } + } + ); + if (req.status != 204) { + status_bar.textContent = 'Failed to Delete Invite: ' + await req.text(); + return; + } + status_bar.textContent = 'Deleted Invite'; + break; + + case 'slot_mute': + slot_id = e.target.getAttribute('data-slot-id'); + reviseSlotLive(slot_id, { is_audio_enabled: false }); + break; + case 'slot_unmute': + slot_id = e.target.getAttribute('data-slot-id'); + reviseSlotLive(slot_id, { is_audio_enabled: true }); + break; + case 'slot_camoff': + slot_id = e.target.getAttribute('data-slot-id'); + reviseSlotLive(slot_id, { is_video_enabled: false }); + break; + case 'slot_camon': + slot_id = e.target.getAttribute('data-slot-id'); + reviseSlotLive(slot_id, { is_video_enabled: true }); + break; + } +}); + +let eventSubController; +// go +async function initGuestStar() { + selector.style.display = 'none'; + controls.style.display = 'block'; + + // check + if (broadcaster_id != moderator_id) { + // only the caster can start/stop + master_controls.classList.add('disableit'); + } else { + master_controls.classList.remove('disableit'); + } + + // lets connect to eventsub + stauts_bar = 'Spawning EventSub'; + eventSubController = new initSocket(true); + eventSubController.on('connected', async (id) => { + status_bar.textContent = `Connected to EventSub WebSockets with ${id}`; + + console.log(eventSubController.eventsub.twitch_websocket_id); + + // subscribe + if (broadcaster_id == moderator_id) { + requstEventSub('channel.guest_star_session.begin', 'beta', { broadcaster_user_id: broadcaster_id }); + requstEventSub('channel.guest_star_session.end', 'beta', { broadcaster_user_id: broadcaster_id }); + } + let resp = await requstEventSub('channel.guest_star_guest.update', 'beta', { broadcaster_user_id: broadcaster_id, moderator_user_id: moderator_id }); + if (resp.status == 403) { + // assume not a guest star moderator + status_bar.textContent = 'You do not appear to be a Guest Star Moderator for this channel'; + controls.classList.add('disableit'); + return; + } + controls.classList.remove('disableit'); + + requstEventSub('channel.guest_star_slot.update', 'beta', { broadcaster_user_id: broadcaster_id, moderator_user_id: moderator_id }); + requstEventSub('channel.guest_star_settings.update', 'beta', { broadcaster_user_id: broadcaster_id, moderator_user_id: moderator_id }); + }); + + bindEventSubTriggers(); + + // lets load current status + loadSession(); + loadSettings(); +} + +async function requstEventSub(type, version, condition) { + return fetch( + 'https://api.twitch.tv/helix/eventsub/subscriptions', + { + method: "POST", + headers: { + ...commonHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + type, + version, + condition, + transport: { + method: "websocket", + session_id: eventSubController.eventsub.twitch_websocket_id + } + }) + } + ); +} + +async function loadSession() { + let url = new URL('https://api.twitch.tv/helix/guest_star/session'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', broadcaster_id ], + [ 'moderator_id', moderator_id ] + ]); + let request = await fetch( + url, + { + method: 'GET', + headers: { + ...commonHeaders + } + } + ); + if (request.status != 200) { + status_bar.textContent = 'Failed to get Session Status: ' + await resp.text(); + return; + } + let session = await request.json(); + console.log('The session count is', session.data.length); + if (session.data.length != 1) { + // no session + master_control.setAttribute('data-function', 'start'); + master_control.value = 'Start Guest Star'; + return; + } + master_control.setAttribute('data-function', 'stop'); + master_control.value = 'End Guest Star'; + + let { id } = session.data[0]; + active_session_id = id; + + // process slots + let { guests } = session.data[0]; + buildGuests(guests); + + // spawn invite check + initInvite(); +} + +/* +Hole in eventsub! +*/ +function initInvite() { + // check invites + refreshInvites(); + + console.log('Suppress initinvite refresh timer'); + return; + + clearInterval(refreshInvitesTimer); + refreshInvitesTimer = setInterval(refreshInvites, 5000); +} + +let refreshInvitesTimer = false; +async function refreshInvites() { + if (active_session_id == '') { + return; + } + console.log('Doing a refreshInvites'); + + let url = new URL('https://api.twitch.tv/helix/guest_star/invites'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', broadcaster_id ], + [ 'moderator_id', moderator_id ], + [ 'session_id', active_session_id ] + ]); + + let invite = await fetch( + url, + { + method: 'GET', + headers: { + ...commonHeaders + } + } + ); + let invited = await invite.json(); + + invite_manager_list.textContent = ''; + + if (invited.data.length == 0) { + return; + } + + let user_ids = []; + + for (let x=0;x { + let { id, display_name } = user; + document.querySelector(`td[data-user-id="${id}"]`).textContent = display_name; + + user_name_cache[id] = user; + }); +} + +async function buildGuests(guests) { + status_bar.textContent = 'Checking the Guest List'; + + let slot_ref = {}; + let user_ids = []; + + let actives = slots.querySelectorAll('.active'); + for (var x=0;x { + let { id, display_name } = user; + slot_ref[id].textContent = display_name; + + user_name_cache[id] = user; + }); +} + +async function loadSettings() { + if (broadcaster_id != moderator_id) { + // cannot do + settings_form.classList.add('disableit'); + return; + } + + let url = new URL('https://api.twitch.tv/helix/guest_star/channel_settings'); + url.search = new URLSearchParams([ + [ 'broadcaster_id', broadcaster_id ] + ]); + let request = await fetch( + url, + { + method: 'GET', + headers: { + ...commonHeaders + } + } + ); + if (request.status != 200) { + status_bar.textContent = 'Failed to get Settings: ' + await resp.text(); + return; + } + let session = await request.json(); + + drawSession(session.data[0]); +} + +function drawSession(session_data) { + let { is_moderator_send_live_enabled, slot_count, is_browser_source_audio_enabled, group_layout } = session_data; + if (is_moderator_send_live_enabled) { + document.querySelector('input[name="is_moderator_send_live_enabled"]').setAttribute('checked', 'checked'); + } else { + document.querySelector('input[name="is_moderator_send_live_enabled"]').removeAttribute('checked'); + } + + active_session_slot_count = slot_count; + document.querySelector('select[name="slot_count"]').value = slot_count; + for (var x=1;x<=5;x++) { + let layout_row = document.getElementById(`slot_${x}`); + if (x <= slot_count) { + layout_row.removeAttribute('disabled'); + layout_row.classList.remove('disabled'); + } else { + layout_row.setAttribute('disabled', 'disabled'); + layout_row.classList.add('disabled'); + } + } + + if (is_browser_source_audio_enabled) { + document.querySelector('input[name="is_browser_source_audio_enabled"]').setAttribute('checked', 'checked'); + } else { + document.querySelector('input[name="is_browser_source_audio_enabled"]').removeAttribute('checked'); + } + document.querySelector('select[name="group_layout"]').value = group_layout.toUpperCase(); +} + +// functions +invite_username_form.addEventListener('submit', async (e) => { + e.preventDefault(); + + if (!active_session_id) { + status_bar.textContent = 'Please Start a Guest Star session first'; + return; + } + + console.log('create invite'); + + let username = invite_username.value; + let userData = await getUserData(username); + + // send invite + let invite = await fetch( + 'https://api.twitch.tv/helix/guest_star/invites', + { + method: 'POST', + headers: { + ...commonHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + broadcaster_id, + moderator_id, + session_id: active_session_id, + guest_id: userData.id + }) + } + ); + if (invite.status == 204) { + // yay + status_bar.textContent = `Invite sent to ${username}`; + // we have to refresh + // as eventsub doesn't give us NEW INVITED users + refreshInvites(); + return; + } + + let data = await invite.json(); + status_bar.textContent = `Failed to invite ${username} - ${data.message}`; +}); +settings_form.addEventListener('submit', async (e) => { + e.preventDefault(); + + console.log('submitting settings'); + + let revise = await fetch( + 'https://api.twitch.tv/helix/guest_star/channel_settings', + { + method: 'PATCH', + headers: { + ...commonHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + broadcaster_id, + + is_moderator_send_live_enabled: (is_moderator_send_live_enabled.checked), + slot_count: slot_count.value, + is_browser_source_audio_enabled: (is_browser_source_audio_enabled.checked), + group_layout: group_layout.value + }) + } + ); + if (revise.status == 204) { + // yay + status_bar.textContent = `Settings Revised`; + return; + } + + let data = await revise.json(); + status_bar.textContent = `Failed to Revise Settings - ${data.message}`; +}); + + + + + +function bindEventSubTriggers() { + eventSubController.on('channel.guest_star_session.begin', ({ metadata, payload }) => { + let { session_id } = payload.event; + active_session_id = session_id; + + initInvite(); + + master_control.setAttribute('data-function', 'stop'); + master_control.value = 'End Guest Star'; + }); + eventSubController.on('channel.guest_star_session.end', ({ metadata, payload }) => { + active_session_id = ''; + + master_control.setAttribute('data-function', 'start'); + master_control.value = 'Start Guest Star'; + }); + + eventSubController.on('channel.guest_star_settings.update', ({ metadata, payload }) => { + drawSession(payload.event); + // load guests + loadSession(); + }); + + eventSubController.on('channel.guest_star_guest.update', ({ metadata, payload }) => { + let { slot_id, guest_user_id, guest_user_login, state } = payload.event; + + console.log(`Update ${slot_id} to ${guest_user_login} with ${state}`); + + //if (state == 'ready') { + if (!slot_id) { + // occurance on a user not in a slot + // so in the invite queue + + if (state == 'removed') { + // invite was removed + return; + } + + // user changed state in the queue + document.querySelector(`td[data-invite-state="${guest_user_id}"]`).textContent = state; + // if it's ready show the slot selector + if (state == 'ready') { + document.getElementById(`invite_to_slot_for_${guest_user_id}`).style.display = 'block'; + } + + return; + } + + // check for and remove from invites + var invite = document.getElementById(`invite_for_${guest_user_id}`); + if (invite) { + invite.remove(); + } + // check for and remove from invites + + let el = document.getElementById(`slot_${slot_id}_guest`); + if (!el) { + // errr + console.log('no el'); + return; + } + + let st = document.getElementById(`slot_${slot_id}_live`); + let mov = document.getElementById(`slot_${slot_id}_move`); + + let mic = document.getElementById(`slot_${slot_id}_mic`); + let cam = document.getElementById(`slot_${slot_id}_cam`); + + if (state == 'invited') { + // this slot was emptied of the user and the user was removed or queued + el.textContent = ''; + st.value = 'backstage'; + mov.removeAttribute('data-user-id'); + + // reset mic/cam + mic.classList.remove('active'); + mic.classList.remove('inactive'); + // cam + cam.classList.remove('active'); + cam.classList.remove('inactive'); + + // add the user _back_ to the invite list + refreshInvites();//cop out + + return; + } + + el.textContent = guest_user_login; + + console.log('making state', state); + st.value = state; + + mov.setAttribute('data-user-id', guest_user_id); + + // mic cam + loadSession(); + // I don't know what the mic/cam state is + mic.classList.add('active'); + cam.classList.add('active'); + }); + + + eventSubController.on('channel.guest_star_slot.update', ({ metadata, payload }) => { + console.log('slot update', payload.event); + let { slot_id, host_video_enabled, host_audio_enabled, host_volume } = payload.event; + let { guest_user_id, guest_user_login, guest_user_name } = payload.event; + + // redraw the table line for slot_id + let targetRow = document.getElementById(`slot_${slot_id}`); + targetRow.setAttribute('data-user-id', guest_user_id); + + let targetGuest = document.getElementById(`slot_${slot_id}_guest`); + let targetLive = document.getElementById(`slot_${slot_id}_live`); + let mic = document.getElementById(`slot_${slot_id}_mic`); + let cam = document.getElementById(`slot_${slot_id}_cam`); + + if (guest_user_id == null) { + // the slot was emptied + targetGuest.textContent = ''; + targetLive.value = 'backstage'; + mic.classList.remove('active'); + mic.classList.remove('inactive'); + cam.classList.remove('active'); + cam.classList.remove('inactive'); + + return; + } + + targetGuest.textContent = guest_user_name; + //targetLive.value = 'backstage';// missing data.... + + if (host_audio_enabled) { + mic.classList.add('active'); + mic.classList.remove('inactive'); + } else { + mic.classList.remove('active'); + mic.classList.add('inactive'); + } + + if (host_video_enabled) { + cam.classList.add('active'); + cam.classList.remove('inactive'); + } else { + cam.classList.remove('active'); + cam.classList.add('inactive'); + } + }); +} + + +/* +Slotting +*/ +async function slotUser(e) { + let guest_id = e.target.closest('tr').getAttribute('data-user-id'); + let slot_id = e.target.value; + + if (slot_id > active_session_slot_count) { + status_bar.textContent = `Cannot add to slot due to slot max ${slot_id} is > ${active_session_slot_count}`; + return; + } + + console.log(`Putting ${guest_id} into ${slot_id}`); + status_bar.textContent = `Putting ${guest_id} into ${slot_id}`; + + let slotAttempt = await fetch( + 'https://api.twitch.tv/helix/guest_star/slot', + { + method: 'POST', + headers: { + ...commonHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + broadcaster_id, + moderator_id, + session_id: active_session_id, + slot_id, + guest_id + }) + } + ); + + if (slotAttempt.status == 204) { + // yay + status_bar.textContent = `Slotted ${guest_id} into ${slot_id}`; + + // reload + console.log('Skip reload'); + //loadSession(); + return; + } + + let data = await slotAttempt.json(); + status_bar.textContent = `Failed to Slot User - ${data.message}`; +} + +async function makeSlotLive(slot_id, is_live) { + status_bar.textContent = `Making ${slot_id} into ${is_live ? 'Live' : 'Backstage'}`; + + let slotAttempt = await fetch( + 'https://api.twitch.tv/helix/guest_star/slot_settings', + { + method: 'PATCH', + headers: { + ...commonHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + broadcaster_id, + moderator_id, + session_id: active_session_id, + slot_id, + is_live + }) + } + ); + + if (slotAttempt.status == 204) { + // yay + status_bar.textContent = `Updated ${slot_id} into ${is_live ? 'Live' : 'Backstage'}`; + return; + } + + let data = await slotAttempt.json(); + status_bar.textContent = `Failed to Update Slot - ${data.message}`; +} + async function reviseSlotLive(slot_id, settings) { + status_bar.textContent = `Revising ${slot_id}`; + + let slotAttempt = await fetch( + 'https://api.twitch.tv/helix/guest_star/slot_settings', + { + method: 'PATCH', + headers: { + ...commonHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + broadcaster_id, + moderator_id, + session_id: active_session_id, + slot_id, + ...settings + }) + } + ); + + if (slotAttempt.status == 204) { + // yay + status_bar.textContent = `Revised ${slot_id}`; + + // no eventsub for the result + // request guest/redraw + console.log('Skip full reload'); + + return; + } + + let data = await slotAttempt.json(); + status_bar.textContent = `Failed to Update Slot - ${data.message}`; + } + +async function moveSlot(source_slot_id, destination_slot_id) { + if (destination_slot_id > active_session_slot_count) { + status_bar.textContent = `Cannot change slot due to slot max ${destination_slot_id} is > ${active_session_slot_count}`; + //return; + } + + status_bar.textContent = `Moving ${source_slot_id} into ${destination_slot_id}`; + + let slotAttempt = await fetch( + 'https://api.twitch.tv/helix/guest_star/slot', + { + method: 'PATCH', + headers: { + ...commonHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + broadcaster_id, + moderator_id, + session_id: active_session_id, + source_slot_id, + destination_slot_id + }) + } + ); + + if (slotAttempt.status == 204) { + // yay + status_bar.textContent = `Moved ${source_slot_id} into ${destination_slot_id}`; + + // no eventsub for the result + // request guest/redraw + console.log('skip reload'); + //loadSession(); + + return; + } + + let data = await slotAttempt.json(); + status_bar.textContent = `Failed to Move Slot - ${data.message}`; +} +async function emptySlot(slot_id, guest_id, should_reinvite_guest) { + status_bar.textContent = `Removing Guest in ${slot_id} with ${should_reinvite_guest}`; + + let slotAttempt = await fetch( + 'https://api.twitch.tv/helix/guest_star/slot', + { + method: 'DELETE', + headers: { + ...commonHeaders, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + broadcaster_id, + moderator_id, + session_id: active_session_id, + guest_id, + slot_id, + should_reinvite_guest + }) + } + ); + + if (slotAttempt.status == 204) { + // yay + status_bar.textContent = `Removed Guest in ${slot_id} with ${should_reinvite_guest}`; + return; + } + + let data = await slotAttempt.json(); + status_bar.textContent = `Failed to Empty Slot - ${data.message}`; +} + +/* +Slot Control +*/ +slots.addEventListener('change', (e) => { + let func = e.target.getAttribute('data-function'); + if (!func) { + return; + } + let slot_id = e.target.getAttribute('data-slot-id'); + var user_id = e.target.closest('tr').getAttribute('data-user-id'); + + switch (func) { + case 'slot_guest': + let destination = e.target.value; + + // reset the form + e.target.value = slot_id; + if (destination == slot_id) { + return; + } + + if (destination == 'Remove') { + console.log('Removing the user', slot_id, user_id); + emptySlot(slot_id, user_id, false); + } else if (destination == 'Queue') { + console.log('ReQueuing the user', slot_id, user_id); + emptySlot(slot_id, user_id, true); + } else { + moveSlot(slot_id, destination); + } + + break; + + case 'slot_live': + let is_live = false; + if (e.target.value == 'live') { + is_live = true; + } + makeSlotLive(slot_id, is_live); + break; + } +}); diff --git a/examples/guest_star/controller/index.html b/examples/guest_star/controller/index.html new file mode 100644 index 0000000..9306c83 --- /dev/null +++ b/examples/guest_star/controller/index.html @@ -0,0 +1,318 @@ + + + Controller | Guest Star | Twitch API Example + + + + + +

This example will first ask you to login with Implicit Auth to get an API Token to use.

+

Get the code for this example on Github or just View the source instead

+

The Moderate Channel stuff isn't setup right

+ +

After testing you can Disconnect the "Barry's GitHub Examples" Application on the Connections page

+ +
+ + + + + + + + + + diff --git a/examples/guest_star/obs_assist/README.md b/examples/guest_star/obs_assist/README.md new file mode 100644 index 0000000..12cb48b --- /dev/null +++ b/examples/guest_star/obs_assist/README.md @@ -0,0 +1,72 @@ +## What is this example + +This is an example tool to help streamers interact Guest Start with OBS 29+ + +## Instruction for Use + +- Connect to OBS + +First it'll ask you to connect to OBS 29+ via OBS WebSocket. +So you will need to have configured the OBS WebSocket if you have not already. + +- Select a Scene + +Then Select a Scene to operate against + +- Login with Twitch + +Click on Connect with Twitch to autheticate, it'll request Read Only permission for your channels Guest Star settings. + +- Add/Remove Slots + +It'll see how many Slots you have configured on Guest Star, and provide Add/Remove buttons for each slot. + +- Click Add or Remove + +This button will then add (or remove) a browser source to the selected OBS scene. +It'll be 640x360 with "OBS Controls Volume" enabled. + +You'll then want to move it in OBS as needed (or resize) + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/guest_star/obs_assist/) + +## Reference Documentation + +### Twitch + +- [Implict Authentication](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#implicit-grant-flow) - to get a Twitch Token to use +- [Get Users](https://dev.twitch.tv/docs/api/reference#get-users) - we need to know who you are on Twitch +- [Get Channel Guest Star Settings](https://dev.twitch.tv/docs/api/reference/#get-channel-guest-star-settings) - to get the Slots and Layout configured + +### OBS + +The following calls from [OBS Websocket Protocol](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md) are utilised. +And to utilise the OBS websocket we use [obs-websocket-js](https://github.com/obs-websocket-community-projects/obs-websocket-js) + +#### Requests + +Asking or telling OBS to do things + +- [GetSceneList](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#getscenelist) - to get what scenes you have +- [GetInputList](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#getinputlist) - to see what inputs you have +- [GetSceneItemList](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#GetSceneItemList) - getting what is added to a given scene +- [CreateInput](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#createinput) - to add/create a Guest Star Slot into OBS +- [RemoveInput](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#removeinput) - to remove/delete a Guest Star Slot into OBS +- [GetInputVolume](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#GetInputVolume) - to get the input volume of the source in OBS +- [SetInputVolume](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#SetInputVolume) - to set the input volume of the source in OBS +- [GetInputMute](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#GetInputMute) - to get the inputs mute status of the source in OBS +- [ToggleInputMute](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#ToggleInputMute) - to toggle the inputs mute status of the source in OBS +- [GetSceneItemEnabled](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#GetSceneItemEnabled) - is a Source visible or not +- [SetSceneItemEnabled](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#SetSceneItemEnabled) - setting if a Source visible or not + +#### Events + +Asking OBS to send us information _as it happens_ + +- [InputVolumeChanged](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#inputvolumechanged) - Monitor for Volume changes +- [InputMuteStateChanged](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#InputMuteStateChanged) - Monitor for Mute state changes +- [SceneItemEnableStateChanged](https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#SceneItemEnableStateChanged) - Monitor for Visibility state changes diff --git a/examples/guest_star/obs_assist/index.html b/examples/guest_star/obs_assist/index.html new file mode 100644 index 0000000..6035dee --- /dev/null +++ b/examples/guest_star/obs_assist/index.html @@ -0,0 +1,63 @@ + + + OBS Assistant | Guest Star | Twitch API Example + + + +

This example will first ask you to login with Implicit Auth to get an API Token to use.

+

Get the code for this example on Github or just View the source instead

+

After testing you can Disconnect the "Barry's GitHub Examples" Application on the Connections page

+ +

Note: this tool should work perfectly fine IF running on the same computer as OBS.

+ +
+
+
Please Authorize to continue
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+

OBS Scenes

+

Which scene would you like to add/remove Guest Star from

+
+
+
+
+
Please Authorize to continue
+ +
+ Authorize with Twitch + +
+
+

Guest Star Slots

+
+
+
+
+ + + + + + + diff --git a/examples/guest_star/obs_assist/script.js b/examples/guest_star/obs_assist/script.js new file mode 100644 index 0000000..e62a55a --- /dev/null +++ b/examples/guest_star/obs_assist/script.js @@ -0,0 +1,436 @@ +// These are set for the GitHub Pages Example +// Substitute as needed +var client_id = 'hozgh446gdilj5knsrsxxz8tahr3koz'; +var redirect = `https://${window.location.host}/twitch_misc/`; +var access_token = ''; + +let guest_star_template = 'https://dashboard.twitch.tv/widgets/guest-star/[USERNAME]?display=single&slot=[SLOT]#auth=[AUTH]'; + +let commonHeaders = {}; +let broadcaster_login = ''; +let broadcaster_id = ''; +let gs_browser_source_token = ''; + +authorize.setAttribute('href', 'https://id.twitch.tv/oauth2/authorize?client_id=' + client_id + '&redirect_uri=' + encodeURIComponent(redirect) + '&response_type=token&scope=channel:read:guest_star'); + +async function processToken(token) { + access_token = token; + + commonHeaders = { + 'Accept': 'application/json', + 'Client-ID': client_id, + 'Authorization': `Bearer ${access_token}` + } + + twitch_status_bar.textContent = 'Loading'; + let broadcaster = await getOneHelix('users'); + if (!broadcaster) { + return; + } + broadcaster_id = broadcaster.id; + broadcaster_login = broadcaster.login; + + authorize.remove(); + + twitch_status_bar.textContent = `Hello ${broadcaster.display_name}`; + + loadGuestStar(); +} +async function getOneHelix(path) { + let url = new URL(`https://api.twitch.tv/helix/${path}`); + let resp = await fetch( + url, + { + method: 'GET', + headers: { + ...commonHeaders + } + } + ); + if (resp.status != 200) { + twitch_status_bar.textContent = `Failed to load ${path}: ` + await resp.text(); + return; + } + let data = await resp.json(); + if (data.data.length == 1) { + return data.data[0]; + } + twitch_status_bar.textContent = 'Failed to find one record'; +} + +async function loadGuestStar() { + let guestStar = await getOneHelix( + `guest_star/channel_settings?broadcaster_id=${broadcaster_id}&moderator_id=${broadcaster_id}` + ); + if (!guestStar) { + return; + } + let { slot_count, is_browser_source_audio_enabled, group_layout, browser_source_token } = guestStar; + + twitch_status_bar.textContent = `We found you are using ${group_layout} with ${slot_count} slots`; + gs_browser_source_token = browser_source_token; + + let obsExistingInputs = await obs.call('GetInputList', { + inputKind: 'browser_source' + }); + let obsSceneInputs = await obs.call('GetSceneItemList', { + sceneName: obsControllingScene + }); + + guest_star_slots.textContent = ''; + for (let x=1;x<=slot_count;x++) { + let gs = document.createElement('div'); + gs.classList.add('guest_star_slot'); + guest_star_slots.append(gs); + + let bt = document.createElement('button'); + gs.append(bt); + bt.classList.add('guest_star_slot_control'); + bt.setAttribute('data-slot', x); + + bt.textContent = 'Add'; + + let targetInputName = `Guest Star: Slot ${x}`; + for (var y=0;y { + loadGuestStar(); +}); + +guest_star_slots.addEventListener('click', async (e) => { + if (!obsControllingScene || obsControllingScene == '') { + twitch_status_bar.textContent = 'Please Select an OBS Scene first'; + return; + } + + if (e.target.classList.contains('guest_star_slot_control')) { + do_guest_star_slot_control(e); + return; + } + if (e.target.classList.contains('guest_star_mute_control')) { + do_guest_star_mute_control(e); + return; + } + if (e.target.classList.contains('guest_star_visible_control')) { + do_guest_star_visible_control(e); + return; + } +}); + +async function do_guest_star_slot_control(e) { + let slot = e.target.getAttribute('data-slot'); + let url = guest_star_template; + url = url.replace('[USERNAME]', broadcaster_login); + url = url.replace('[SLOT]', slot); + url = url.replace('[AUTH]', gs_browser_source_token); + + //console.log(url);return; + + let inputName = `Guest Star: Slot ${slot}`; + let found = false; + // check if exists + let existing = await obs.call('GetInputList', { + inputKind: 'browser_source' + }); + for (var x=0;x { + e.preventDefault(); + + initOBS( + obs_ip.value, + obs_port.value, + obs_password.value + ) +}); + +const obs = new OBSWebSocket(); + +async function initOBS(ip, port, password) { + // connect + try { + await obs.connect(`ws://${ip}:${port}`, password, { + rpcVersion: 1, + eventSubscriptions: OBSWebSocket.EventSubscription.All + }); + obs_status_bar.textContent = 'Connected to OBS'; + } catch (error) { + obs_status_bar.textContent = `Failed to Connect to OBS: ${error.message}`; + console.error('Failed to connect', error.code, error.message); + return; + } + twitch.classList.remove('disable'); + connect_to_obs.style.display = 'none'; + + // OBS Get scenes and current scene + let SceneList = await obs.call('GetSceneList'); + obs_scenes.textContent = ''; + + let { scenes, currentProgramSceneName } = SceneList; + + scenes.forEach(scene => { + let { sceneName } = scene; + let d = document.createElement('div'); + d.classList.add('bigbutton'); + d.textContent = sceneName; + if (sceneName == currentProgramSceneName) { + d.classList.add('selected'); + obsControllingScene = sceneName; + } + obs_scenes.append(d); + }); + + // the meters! + //obs.on('InputVolumeMeters', (data) => { + // console.log(data); + //}); + obs.on('InputVolumeChanged', (data) => { + //console.log(data); + updateVolume(data); + }); + obs.on('InputMuteStateChanged', (data) => { + //console.log('Mute Event', data); + updateMute(data); + }); + + obs.on('SceneItemEnableStateChanged', (data) => { + //console.log(data); + updateState(data); + }); +} + + + async function checkVolume(inputName, el, elm) { + let { inputVolumeMul, inputVolumeDb } = await obs.call( + 'GetInputVolume', + { + inputName + } + ); + console.log(`For ${inputName} the vol is MUL:${inputVolumeMul}/DB:${inputVolumeDb}`); + el.value = inputVolumeMul; + + let tel = document.querySelector(`div[data-obs-inputName="${inputName}"]`); + let dsp = parseFloat(inputVolumeDb).toFixed(2); + tel.textContent = `${dsp}dB`; + + // bind a change function + el.addEventListener('mousedown', async (e) => { + el.classList.add('beingDragged'); + }); + el.addEventListener('mouseup', async (e) => { + el.classList.remove('beingDragged'); + }); + el.addEventListener('input', async (e) => { + console.log('Volumne Change', inputName, e.target.value); + + await obs.call( + 'SetInputVolume', + { + inputName, + inputVolumeMul: parseFloat(e.target.value) + } + ); + }); + + let { inputMuted } = await obs.call( + 'GetInputMute', + { + inputName + } + ); + if (inputMuted) { + elm.textContent = 'Muted'; + } else { + elm.textContent = 'Mute'; + } + elm.setAttribute('data-muted', inputMuted); + } + function updateVolume(data) { + let { inputName, inputVolumeDb, inputVolumeMul } = data; + + let tel = document.querySelector(`.guest_star_db[data-obs-inputName="${inputName}"]`); + let dsp = parseFloat(inputVolumeDb).toFixed(2); + tel.textContent = `${dsp}dB`; + + let el = document.querySelector(`input[data-obs-inputName="${inputName}"]`); + if (el.classList.contains('beingDragged')) { + return; + } + console.log(`Change ${inputName} to MUL:${inputVolumeMul}/DB:${inputVolumeDb}`); + el.value = inputVolumeMul; + } + + function updateMute(data) { + let { inputMuted, inputName } = data; + let elm = document.querySelector(`.guest_star_mute_control[data-obs-inputName="${inputName}"]`); + if (inputMuted) { + elm.textContent = 'Muted'; + } else { + elm.textContent = 'Mute'; + } + elm.setAttribute('data-muted', inputMuted); + } + async function do_guest_star_mute_control(e) { + let inputName = e.target.getAttribute('data-obs-inputName'); + await obs.call( + 'ToggleInputMute', + { + inputName + } + ); + } + + function updateState(data) { + let { sceneItemEnabled, sceneItemId, sceneName } = data; + console.log('State', { sceneItemEnabled, sceneItemId, sceneName }); + if (sceneName != obsControllingScene) { + return; + } + let vbt = document.querySelector(`.guest_star_visible_control[data-obs-sceneitemid="${sceneItemId}"]`); + if (!vbt) { + return; + } + if (sceneItemEnabled) { + vbt.textContent = 'Hide'; + } else { + vbt.textContent = 'Show'; + } + vbt.setAttribute('data-hidden', !sceneItemEnabled); + } + async function do_guest_star_visible_control(e) { + let inputName = e.target.getAttribute('data-obs-inputName'); + // consider moving to a live lookup... + let sceneItemId = parseInt(e.target.getAttribute('data-obs-sceneItemId')); + + let { sceneItemEnabled } = await obs.call( + 'GetSceneItemEnabled', + { + sceneName: obsControllingScene, + sceneItemId + } + ); + sceneItemEnabled = sceneItemEnabled ? false : true; + await obs.call( + 'SetSceneItemEnabled', + { + sceneName: obsControllingScene, + sceneItemId, + sceneItemEnabled + } + ); + } + + +let obsControllingScene = ''; +obs_scenes.addEventListener('click', (e) => { + let el = document.querySelector('.bigbutton.selected'); + if (el) { + el.classList.remove('selected'); + } + e.target.classList.add('selected'); + obsControllingScene = e.target.textContent; +}); diff --git a/examples/guest_star/obs_assist/style.css b/examples/guest_star/obs_assist/style.css new file mode 100644 index 0000000..63f4b72 --- /dev/null +++ b/examples/guest_star/obs_assist/style.css @@ -0,0 +1,128 @@ +* { + background: rgb(14, 14, 16); + color: rgb(239, 239, 241); + font-family: Roobert, Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; +} +a { + color: rgb(169, 112, 255); +} +table { + width: 100%; + border-top: 1px solid rgb(239, 239, 241); + border-left: 1px solid rgb(239, 239, 241); +} +th, td { + border-right: 1px solid rgb(239, 239, 241); + border-bottom: 1px solid rgb(239, 239, 241); + padding: 5px 10px; +} +input[disabled] { + background: #3e0000; +} +input, select { width: 100%; } +input[type="submit"] { + background: rgb(82, 0, 82); + cursor: pointer; +} +input[type="submit"]:hover { + background: rgb(189, 1, 189); +} +input[type="button"], button { + background: #7c63ac; + cursor: pointer; +} +input[type="range"] { + -webkit-appearance: slider-vertical; /* Chromium */ + width: 8px; + height: 175px; + padding: 0 5px; +} + +.row { + display: flex; +} +.col { + flex-grow: 1; + min-width: 50%; +} +@media only screen and (max-width: 600px) { + .row { + flex-direction: column; + } + .col { + width: 100%; + } +} + +#authorize { + background-image: url('/twitch_misc/twitch_login.png'); + height: 32px; + display: block; + padding-left: 180px; + background-repeat: no-repeat; + line-height: 32px; +} + +#obs_scenes { + display: flex; +} +.bigbutton { + padding: 30px; + border: 1px solid grey; + cursor: pointer; +} +#obs_scenes .bigbutton:first-child { + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; +} +#obs_scenes .bigbutton:last-child { + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; +} + +.bigbutton:hover { + background: grey; +} +.bigbutton.selected { + background: green; +} + +#guest_star_slots { + display: flex; +} +.guest_star_slot { + padding: 30px 10px; + box-sizing: border-box; + border: 1px solid grey; + text-align: center; + min-width: 20%; + max-width: 20%; +} +#guest_star_slots .guest_star_slot:first-child { + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; +} +#guest_star_slots .guest_star_slot:last-child { + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; +} + +button[data-hidden="true"], +button[data-muted="true"] { + background: red; +} + + +.disable { + position: relative; +} +.disable:before { + position: absolute; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; + background: black; + opacity: 0.5; + content: ''; +} diff --git a/examples/moderator_dashboard/README.md b/examples/moderator_dashboard/README.md new file mode 100644 index 0000000..da983c0 --- /dev/null +++ b/examples/moderator_dashboard/README.md @@ -0,0 +1,112 @@ +## What is this example + +This is an example that demonstrates the duality of Two Access Tokens. + +This example essentially creates a "moderator only" Dashboard. + +After setup it'll prompt for the broadcaster to Authenticate with the `moderation:read` scope. +Then store and refresh that key as needed. Redis is used to store the access/refresh tokens. + +After the Application has been seeded with this key, the Application will only allow the broadcaster and moderators to login. + +You could use such a system to protect a bot control panel, or other system where you would need to only permit the broadcaster or channel moderator to login. + +Conceivably, you can use this example to create a subscribers only "portal", you would substitiute `moderation:read` scope for `moderation:read+channel:read:subscriptions` (this would allow moderators _and_ subscribers to login to this "portal"). And update the `moderatorCheck` function to include a call to [Get Broadcaster Subscriptions](https://dev.twitch.tv/docs/api/reference#get-broadcaster-subscriptions) (usually call Subscriber check and if not a sub call moderators would be the preferred order). + +This flow is demonstrated in `server_subscriber.js` + +So: + +- `server.js` - a Moderator only Portal +- `server_subscriber.js` - a Subscriber or Moderator only Portal + +## Reference Documentation + +- [Get User oAuth Token](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth#oauth-authorization-code-flow) +- [Get Moderators](https://dev.twitch.tv/docs/api/reference#get-moderators) +- [Get Broadcaster Subscriptions](https://dev.twitch.tv/docs/api/reference#get-broadcaster-subscriptions) + +## Setting up the config + +You can set the redirect URI as needed but `http://127.0.0.1:8000` will suffice if you don't know what to use. + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy `config_sample.json` to `config.json` +- Copy the Client ID from the Dev Console to `client_id` +- Hit "Generate Secret" on the Dev Console and copy the displayed secret into `client_secret` +- Pick a port to run the example on, 8000 should suffice, and update `listen` as needed +- Update the `redirect_uri` to `http://127.0.0.1:8000` in the config.json and apply this to the redirect URI in the dev console +- Set the `broadcaster_id` to the needed broadcaster ID, this stops another broadcaster authentciate and breaking in. + +*Note*: in theory you could update the code to 'lock' to the first broadcaster that logs in, but if the keys _completely_ die, a moderator could accidentally auth and then the "owner" is set to the wrong user, so it's wise to hardcode/lock the user ID. But this is an example of a product, there are alternative ways to make sure the 'owner'/broadcaster doesn't change. + +## Running the example + +- `npm install` +- `npm start` + +## Nginx and Cookie Security + +This is an example, so doesn't contain all the _best_ security practices. +Since this uses cookies to manage logins you should change the session code to something like + +``` +app.use(session({ + store: new RedisStore({ + client: redis_client + }), + secret, + resave: true, + saveUninitialized: false, + cookie: { + secure: true, + maxAge: something + }, + rolling: true +})); +``` + +See also [Production Best Practices: Security](https://expressjs.com/en/advanced/best-practice-security.html#use-cookies-securely) + +If you are putting this nodeJS HTTP server beind NGINX, your NGINX declartion for the location will need additional fields: + +``` +server { + listen IPv4:443; + listen [::]:443; + + server_name example.com; + root /some/path/to/files; + + ssl on; + ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; + + location / { + # Cookie Flags + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # Cookie Flags + proxy_set_header Host $http_host; + proxy_set_header X-NginX-Proxy true; + # Other + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + + proxy_pass http://this_server_relay; + } +} + +upstream this_server_relay { + server 127.0.0.1:5000; + keepalive 8; +} + +``` diff --git a/examples/moderator_dashboard/config_sample.json b/examples/moderator_dashboard/config_sample.json new file mode 100644 index 0000000..c90525d --- /dev/null +++ b/examples/moderator_dashboard/config_sample.json @@ -0,0 +1,10 @@ +{ + "listen": 8000, + "twitch": { + "client_id": "MyClientID", + "client_secret": "MyClientSecret", + "redirect_uri": "http://MyReditectURI:8000/", + "broadcaster_id": "ID_of_channel_tool_is_for" + }, + "appname": "myapplication" +} diff --git a/examples/moderator_dashboard/package-lock.json b/examples/moderator_dashboard/package-lock.json new file mode 100644 index 0000000..dd74353 --- /dev/null +++ b/examples/moderator_dashboard/package-lock.json @@ -0,0 +1,2481 @@ +{ + "name": "chatlog", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "chatlog", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "connect-redis": "^6.1.3", + "express": "^4.19.2", + "express-session": "^1.18.0", + "got": "^11.8.5", + "pug": "^3.0.2", + "redis": "^3.1.2" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz", + "integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==" + }, + "node_modules/@babel/parser": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.16.tgz", + "integrity": "sha512-c/+u9cqV6F0+4Hpq01jnJO+GLp2DdT63ppz9Xa+6cHaajM9VFzK/iDXiKK65YtpeVwu+ctfS6iqlMqRgQRzeCw==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.13.tgz", + "integrity": "sha512-oKrdZTld2im1z8bDwTOQvUbxKwE+854zc16qWZQlcTqMN00pWxHQ4ZeOq0yDMnisOpRykH2/5Qqcrk/OlbAjiQ==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.12.11", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz", + "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", + "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "*", + "@types/node": "*", + "@types/responselike": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" + }, + "node_modules/@types/keyv": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", + "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.5.tgz", + "integrity": "sha512-w3mrvNXLeDYV1GKTZorGJQivK6XLCoGwpnyJFbJVK/aTBQUxOCaa/GlFAAN3OTDFcb7h5tiFG+YXCO2By+riZw==" + }, + "node_modules/@types/responselike": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", + "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "node_modules/assert-never": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz", + "integrity": "sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==" + }, + "node_modules/babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "dependencies": { + "@babel/types": "^7.9.6" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", + "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha1-x84o821LzZdE5f/CxfzeHHMmH8A=", + "dependencies": { + "is-regex": "^1.0.3" + } + }, + "node_modules/clone-response": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "dependencies": { + "mimic-response": "^1.0.0" + } + }, + "node_modules/connect-redis": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz", + "integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "dependencies": { + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "dependencies": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.5", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.5.tgz", + "integrity": "sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz", + "integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==", + "dependencies": { + "has": "^1.0.3" + } + }, + "node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "dependencies": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "node_modules/is-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz", + "integrity": "sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==", + "dependencies": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "node_modules/jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=", + "dependencies": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "node_modules/keyv": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.4.tgz", + "integrity": "sha512-vqNHbAc8BBsxk+7QBYLW0Y219rWcClspR6WSeoHYKG5mnsSoOH+BL1pWq02DDCVdvvuUny5rkBlzMRzoqc+GIg==", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pug": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", + "integrity": "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==", + "dependencies": { + "pug-code-gen": "^3.0.3", + "pug-filters": "^4.0.0", + "pug-lexer": "^5.0.1", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "node_modules/pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", + "dependencies": { + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" + } + }, + "node_modules/pug-code-gen": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.3.tgz", + "integrity": "sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==", + "dependencies": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==" + }, + "node_modules/pug-filters": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", + "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", + "dependencies": { + "constantinople": "^4.0.1", + "jstransformer": "1.0.0", + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0", + "resolve": "^1.15.1" + } + }, + "node_modules/pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", + "dependencies": { + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "dependencies": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "dependencies": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==" + }, + "node_modules/pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "dependencies": { + "pug-error": "^2.0.0" + } + }, + "node_modules/pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==" + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "dependencies": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-redis" + } + }, + "node_modules/redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "dependencies": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, + "node_modules/responselike": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", + "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", + "dependencies": { + "lowercase-keys": "^2.0.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "engines": { + "node": ">=4" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ=" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "dependencies": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + } + }, + "dependencies": { + "@babel/helper-validator-identifier": { + "version": "7.12.11", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz", + "integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==" + }, + "@babel/parser": { + "version": "7.12.16", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.16.tgz", + "integrity": "sha512-c/+u9cqV6F0+4Hpq01jnJO+GLp2DdT63ppz9Xa+6cHaajM9VFzK/iDXiKK65YtpeVwu+ctfS6iqlMqRgQRzeCw==" + }, + "@babel/types": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.13.tgz", + "integrity": "sha512-oKrdZTld2im1z8bDwTOQvUbxKwE+854zc16qWZQlcTqMN00pWxHQ4ZeOq0yDMnisOpRykH2/5Qqcrk/OlbAjiQ==", + "requires": { + "@babel/helper-validator-identifier": "^7.12.11", + "lodash": "^4.17.19", + "to-fast-properties": "^2.0.0" + } + }, + "@sindresorhus/is": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz", + "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==" + }, + "@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "requires": { + "defer-to-connect": "^2.0.0" + } + }, + "@types/cacheable-request": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz", + "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==", + "requires": { + "@types/http-cache-semantics": "*", + "@types/keyv": "*", + "@types/node": "*", + "@types/responselike": "*" + } + }, + "@types/http-cache-semantics": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", + "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==" + }, + "@types/keyv": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz", + "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==", + "requires": { + "@types/node": "*" + } + }, + "@types/node": { + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.5.tgz", + "integrity": "sha512-w3mrvNXLeDYV1GKTZorGJQivK6XLCoGwpnyJFbJVK/aTBQUxOCaa/GlFAAN3OTDFcb7h5tiFG+YXCO2By+riZw==" + }, + "@types/responselike": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", + "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==", + "requires": { + "@types/node": "*" + } + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==" + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=" + }, + "assert-never": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz", + "integrity": "sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw==" + }, + "babel-walk": { + "version": "3.0.0-canary-5", + "resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz", + "integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==", + "requires": { + "@babel/types": "^7.9.6" + } + }, + "body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + } + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==" + }, + "cacheable-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz", + "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==", + "requires": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + } + }, + "call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + } + }, + "character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha1-x84o821LzZdE5f/CxfzeHHMmH8A=", + "requires": { + "is-regex": "^1.0.3" + } + }, + "clone-response": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", + "requires": { + "mimic-response": "^1.0.0" + } + }, + "connect-redis": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-6.1.3.tgz", + "integrity": "sha512-aaNluLlAn/3JPxRwdzw7lhvEoU6Enb+d83xnokUNhC9dktqBoawKWL+WuxinxvBLTz6q9vReTnUDnUslaz74aw==" + }, + "constantinople": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz", + "integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==", + "requires": { + "@babel/parser": "^7.6.0", + "@babel/types": "^7.6.1" + } + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "requires": { + "safe-buffer": "5.2.1" + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, + "cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "requires": { + "mimic-response": "^3.1.0" + }, + "dependencies": { + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==" + } + } + }, + "defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==" + }, + "define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "requires": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + } + }, + "denque": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz", + "integrity": "sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw==" + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, + "doctypes": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz", + "integrity": "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==" + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "requires": { + "once": "^1.4.0" + } + }, + "es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "requires": { + "get-intrinsic": "^1.2.4" + } + }, + "es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + } + }, + "express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-m93QLWr0ju+rOwApSsyso838LQwgfs44QtOP/WBiwtAgPIo/SAh1a5c6nn2BR6mFNZehTpqKDESzP+fRHVbxwQ==", + "requires": { + "cookie": "0.6.0", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "dependencies": { + "cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + } + } + }, + "finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + } + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" + }, + "get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "requires": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + } + }, + "get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "requires": { + "pump": "^3.0.0" + } + }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "requires": { + "get-intrinsic": "^1.1.3" + } + }, + "got": { + "version": "11.8.5", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.5.tgz", + "integrity": "sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==", + "requires": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + } + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "requires": { + "es-define-property": "^1.0.0" + } + }, + "has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==" + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, + "hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "requires": { + "function-bind": "^1.1.2" + } + }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, + "http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "requires": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-core-module": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz", + "integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==", + "requires": { + "has": "^1.0.3" + } + }, + "is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "requires": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "is-regex": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz", + "integrity": "sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==", + "requires": { + "call-bind": "^1.0.2", + "has-symbols": "^1.0.1" + } + }, + "js-stringify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz", + "integrity": "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==" + }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "jstransformer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz", + "integrity": "sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=", + "requires": { + "is-promise": "^2.0.0", + "promise": "^7.0.1" + } + }, + "keyv": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.4.tgz", + "integrity": "sha512-vqNHbAc8BBsxk+7QBYLW0Y219rWcClspR6WSeoHYKG5mnsSoOH+BL1pWq02DDCVdvvuUny5rkBlzMRzoqc+GIg==", + "requires": { + "json-buffer": "3.0.1" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==" + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==" + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, + "normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==" + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "requires": { + "asap": "~2.0.3" + } + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "pug": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pug/-/pug-3.0.3.tgz", + "integrity": "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==", + "requires": { + "pug-code-gen": "^3.0.3", + "pug-filters": "^4.0.0", + "pug-lexer": "^5.0.1", + "pug-linker": "^4.0.0", + "pug-load": "^3.0.0", + "pug-parser": "^6.0.0", + "pug-runtime": "^3.0.1", + "pug-strip-comments": "^2.0.0" + } + }, + "pug-attrs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz", + "integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==", + "requires": { + "constantinople": "^4.0.1", + "js-stringify": "^1.0.2", + "pug-runtime": "^3.0.0" + } + }, + "pug-code-gen": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.3.tgz", + "integrity": "sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==", + "requires": { + "constantinople": "^4.0.1", + "doctypes": "^1.1.0", + "js-stringify": "^1.0.2", + "pug-attrs": "^3.0.0", + "pug-error": "^2.1.0", + "pug-runtime": "^3.0.1", + "void-elements": "^3.1.0", + "with": "^7.0.0" + } + }, + "pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==" + }, + "pug-filters": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz", + "integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==", + "requires": { + "constantinople": "^4.0.1", + "jstransformer": "1.0.0", + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0", + "resolve": "^1.15.1" + } + }, + "pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", + "requires": { + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" + } + }, + "pug-linker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz", + "integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==", + "requires": { + "pug-error": "^2.0.0", + "pug-walk": "^2.0.0" + } + }, + "pug-load": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz", + "integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==", + "requires": { + "object-assign": "^4.1.1", + "pug-walk": "^2.0.0" + } + }, + "pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "requires": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "pug-runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz", + "integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==" + }, + "pug-strip-comments": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz", + "integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==", + "requires": { + "pug-error": "^2.0.0" + } + }, + "pug-walk": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz", + "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==" + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "requires": { + "side-channel": "^1.0.4" + } + }, + "quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==" + }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "redis": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/redis/-/redis-3.1.2.tgz", + "integrity": "sha512-grn5KoZLr/qrRQVwoSkmzdbw6pwF+/rwODtrOr6vuBRiR/f3rjSTGupbF90Zpqm2oenix8Do6RV7pYEkGwlKkw==", + "requires": { + "denque": "^1.5.0", + "redis-commands": "^1.7.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0" + } + }, + "redis-commands": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.7.0.tgz", + "integrity": "sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==" + }, + "redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=" + }, + "redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=", + "requires": { + "redis-errors": "^1.0.0" + } + }, + "resolve": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", + "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", + "requires": { + "is-core-module": "^2.2.0", + "path-parse": "^1.0.6" + } + }, + "resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, + "responselike": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz", + "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==", + "requires": { + "lowercase-keys": "^2.0.0" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, + "serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + } + }, + "set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "requires": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "requires": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + } + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=" + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ=" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==" + }, + "with": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz", + "integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==", + "requires": { + "@babel/parser": "^7.9.6", + "@babel/types": "^7.9.6", + "assert-never": "^1.2.1", + "babel-walk": "3.0.0-canary-5" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + } + } +} diff --git a/examples/moderator_dashboard/package.json b/examples/moderator_dashboard/package.json new file mode 100644 index 0000000..4ccd706 --- /dev/null +++ b/examples/moderator_dashboard/package.json @@ -0,0 +1,21 @@ +{ + "name": "chatlog", + "version": "1.0.0", + "description": "", + "main": "server.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "connect-redis": "^6.1.3", + "express": "^4.19.2", + "express-session": "^1.18.0", + "got": "^11.8.5", + "pug": "^3.0.2", + "redis": "^3.1.2" + } +} diff --git a/examples/moderator_dashboard/public/style.css b/examples/moderator_dashboard/public/style.css new file mode 100644 index 0000000..1819493 --- /dev/null +++ b/examples/moderator_dashboard/public/style.css @@ -0,0 +1,17 @@ +* { + background: rgb(14, 14, 16); + color: rgb(239, 239, 241); + font-family: Roobert, Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; +} +a { + color: rgb(169, 112, 255); +} +table { + border-top: 1px solid rgb(239, 239, 241); + border-left: 1px solid rgb(239, 239, 241); +} +th, td { + border-right: 1px solid rgb(239, 239, 241); + border-bottom: 1px solid rgb(239, 239, 241); + padding: 5px 10px; +} diff --git a/examples/moderator_dashboard/server.js b/examples/moderator_dashboard/server.js new file mode 100644 index 0000000..ac5b595 --- /dev/null +++ b/examples/moderator_dashboard/server.js @@ -0,0 +1,434 @@ +const fs = require('fs'); +const path = require('path'); + +const got = require('got'); + +const express = require('express'); + +const crypto = require('crypto'); + +/* +Configurations +Note: we don't bother error checking, coz if it errors you got bigger problems +*/ +const config = JSON.parse(fs.readFileSync(path.join( + __dirname, + 'config.json' +))); + +/* +Server +*/ +const app = express(); + +app.set('views', path.join(__dirname, 'views')); +app.set('view engine', 'pug'); +app.locals.basedir = path.join(__dirname, 'views'); + +app.set('view options', { + debug: false, + compileDebug: false +}) + +app.use(express.static(path.join(__dirname, 'public'))); + +/* interfaces */ +const http = require('http').Server(app); +http.listen(config.listen, function() { + console.log('Server raised on', config.listen); +}); + +const redis = require('redis'); +const redis_client = redis.createClient(); +redis_client.on('error', (err) => { + console.error('REDIS Error', err); +}); +//const redis_subscribe = redis.createClient(); +//redis_subscribe.on('error', function (err) { +// console.error('REDIS Error', err); +//}); + +/* +Generate a random string at start up +To secure sessions with +This means when the server restarts, it'll generate a new string +*/ +var secret = crypto.randomBytes(64).toString('hex'); + +/* Session */ +const session = require('express-session'); +const RedisStore = require('connect-redis')(session); +// Usually you'll put the node process +// behind a proxy such as nginx +// so that proxy will handle the SSL Certs +// trust proxy will tell the session handler to trust the SSL ness of the cookie +// see also https://expressjs.com/en/guide/behind-proxies.html +// for other options you may want to use something more specific than a true +app.set('trust proxy', 1); + +// you can set the cookie max age +//cookie: { +// maxAge: (30 * 60 * 1000) + +// For production see the node in the README.md +// ## Nginx and Cookie Security +// https://expressjs.com/en/advanced/best-practice-security.html#use-cookies-securely + +app.use(session({ + store: new RedisStore({ + client: redis_client + }), + secret, + resave: true, + saveUninitialized: false, + cookie: { + secure: false + }, + rolling: true +})); + +/* +Generic Error logger +*/ +app.use((err, req, res, next) => { + //console.log('in here'); + if (err) { + console.log(err); + } + next(err); +}); + +/* +Lets load and setup the streamers authentication keys +*/ +var keystate = false; +function loadKeys() { + redis_client.hget( + config.appname + '_broadcaster', + 'access_token', + (e,access_token) => { + if (e) { + console.error('Redis Problem loading keys', e); + // and crash bigger problems + process.exit(); + } + if (access_token) { + // key exists validate it + got({ + url: 'https://id.twitch.tv/oauth2/validate', + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + access_token + }, + responseType: 'json' + }) + .then(resp => { + // token good + // check age/time left + // 30 minutes + if (resp.body.expires_id <= (30 * 60 * 1000)) { + // regnerate the key + regnerateKey(); + } else { + keystate = access_token; + } + }) + .catch(err => { + if (err.response) { + console.log('Validate error', err.response.statusCode, err.response.body); + } else { + console.log('Bad Validation error', err); + } + // need new keys + }); + } + } + ); +} +loadKeys(); + +function regenerateKey() { + redis_client.hget( + config.appname + '_broadcaster', + 'refresh_token', + (e,refresh_token) => { + if (e) { + console.error('Redis Problem loading keys', e); + // and crash bigger problems + process.exit(); + } + if (!refresh_token) { + console.error('No Refresh Token on file, how did we get here'); + process.exit(); + } + + got({ + url: 'https://id.twitch.tv/oauth2/token', + method: 'POST', + searchParams: { + grant_type: 'refresh', + refresh_token, + client_id: config.twitch.client_id, + client_secret: config.twitch.client_secret + }, + responseType: 'json' + }) + .then(resp => { + storeKeys(resp.body.access_token, resp.body.refresh_token); + }) + .catch(err => { + if (err.response) { + console.log('Refresh error', err.response.statusCode, err.response.body); + } else { + console.log('Bad Refresh error', err); + } + // need new keys + }) + } + ); +} +function storeKeys(access_token, refresh_token, cb) { + // got keys + keystate = access_token; + + // store new keys + redis_client.hmset( + config.appname + '_broadcaster', + 'access_token', + access_token, + 'refresh_token', + refresh_token, + (e,r) => { + if (e) { + console.log('Error Storing new keys', e); + } + + if (cb) { + cb(); + } + } + ); +} + +/* +register some pug globals +*/ +app.use((req, res, next) => { + res.locals.twitch_client_id = config.twitch.client_id; + if (req.session.error) { + res.locals.error = req.session.error; + delete req.session.error; + console.log('Captured error', res.locals.error); + } + if (req.session.auth_error) { + res.locals.auth_error = req.session.auth_error; + delete req.session.auth_error; + console.log('Captured auth error', res.locals.auth_error); + } + + res.locals.user = false; + if (req.session.user) { + res.locals.user = req.session.user; + } + + next(); +}); + +/* +Logout super important +*/ +app.get('/logout', (req,res) => { + req.session.destroy(); + res.redirect('/'); +}); + +/* +Interrupted handler for authentication +*/ +app.use((req,res,next) => { + let { code, error, error_description, scope, state } = req.query; + if (code) { + // first validate the state is valid + state = decodeURIComponent(state); + + console.log(req.session.state, '!=', state); + if (req.session.state != state) { + console.log('state mismatch'); + req.session.auth_error = 'State does not match. Please try again!'; + res.redirect('/'); + return; + } + // done with the state params + delete req.session.state; + + var access_keys = {}; + + // start the oAuth dance + got({ + "url": "https://id.twitch.tv/oauth2/token", + "method": 'POST', + "headers": { + "Accept": "application/json" + }, + "form": { + "client_id": config.twitch.client_id, + "client_secret": config.twitch.client_secret, + code, + "grant_type": "authorization_code", + "redirect_uri": config.twitch.redirect_uri + }, + "responseType": 'json' + }) + .then(resp => { + // oAuth dance success! + access_keys = resp.body; + + // we need to know for whome the access token is for + return got({ + url: 'https://api.twitch.tv/helix/users', + method: 'GET', + headers: { + 'Client-ID': config.twitch.client_id, + 'Authorization': 'Bearer ' + access_keys.access_token + }, + responseType: 'json' + }) + }) + .then(resp => { + if (resp.body && resp.body.data && resp.body.data.length == 1) { + // we got an id + // is it the same ID as the broadcaster + // as the broadcaster is not a moderator on their own channel + req.session.user = resp.body.data[0]; + + if (scope == 'moderation:read') { + // check that it's the configured broadcaster authenticate to prevent take over attacks + if (resp.body.data[0].id != config.twitch.broadcaster_id) { + // wrong broadcaster auth + req.session.auth_error = 'broadcaster Auth for wrong broadcaster'; + res.redirect('/'); + return; + } + + // it's the correct casters key + storeKeys(access_keys.access_token, access_keys.refresh_token, () => { + res.redirect('/'); + }); + } else if (resp.body.data[0].id == config.twitch.broadcaster_id) { + // the broadcaster logged in + req.session.permitted = true; + res.redirect('/'); + } else { + // user loging call moderator check + accessChecks(resp.body.data[0].id, req, res, next); + } + } else { + req.session.error = 'Failed to get your User from Twitch'; + res.redirect('/'); + } + }) + .catch(err => { + if (err.response) { + console.error('Code exchange Error:', err.response.body); + // the oAuth dance failed + req.session.auth_error = 'An Error occured: ' + ((err.response && err.response.body.message) ? err.response.body.message : 'Unknown'); + } else { + req.session.error = 'Code exchange Bad Error', + console.log('Errror', err); + } + res.redirect('/'); + }); + } else if (error) { + req.session.auth_error = 'An Error occured: ' + error_description; + res.redirect('/'); + } else { + next(); + } +}); + +function accessChecks(user_id, req, res, next) { + got({ + url: 'https://api.twitch.tv/helix/moderation/moderators', + method: 'GET', + headers: { + 'Client-ID': config.twitch.client_id, + 'Authorization': 'Bearer ' + keystate + }, + searchParams: { + broadcaster_id: config.twitch.broadcaster_id, + user_id + }, + responseType: 'json' + }) + .then(resp => { + if (resp.body.data && resp.body.data.length == 1) { + // yay + req.session.permitted = true; + // consider next() instead? + // but redirect to clear query params + } else { + req.session.error = 'You are not a moderator, access denied'; + } + }) + .catch(err => { + console.error('Error body:', err.response.body); + // the oAuth dance failed + req.session.error = 'An Error occured: ' + ((err.response && err.response.body.message) ? err.response.body.message : 'Unknown'); + }) + .finally(() => { + res.redirect('/'); + }); +} + +/* +Interrupted handler for caster keys needed +*/ +app.use((req,res,next) => { + // stupidity check + if (req.originalUrl.indexOf('.') >= 0) { + res.status(404); + return; + } + // end stupidity check + + if (!keystate) { + // need broadcaster keys + // interrupt all page loads for the key request + + // setup a nonce/state + req.session.state = crypto.randomBytes(16).toString('base64'); + + // build the URL + var authenticate = 'https://id.twitch.tv/oauth2/authorize' + + '?client_id=' + config.twitch.client_id + + '&redirect_uri=' + encodeURIComponent(config.twitch.redirect_uri) + + '&response_type=code' + + '&scope=moderation:read' + + '&state=' + encodeURIComponent(req.session.state); + + res.render('broadcaster_keys_needed', { + authenticate + }); + } else if (!req.session.permitted) { + req.session.state = crypto.randomBytes(16).toString('base64'); + + var authenticate = 'https://id.twitch.tv/oauth2/authorize' + + '?client_id=' + config.twitch.client_id + + '&redirect_uri=' + encodeURIComponent(config.twitch.redirect_uri) + + '&response_type=code' + + '&state=' + encodeURIComponent(req.session.state); + //+ '&scope=' // no scopes we just need userID + // there is an argument to use OIDC here + + // login + res.render('moderator_login', { + authenticate + }); + } else { + next(); + } +}); + +app.get('/', (req,res) => { + res.render('moderator_valid'); +}); diff --git a/examples/moderator_dashboard/server_subscriber.js b/examples/moderator_dashboard/server_subscriber.js new file mode 100644 index 0000000..91d48dd --- /dev/null +++ b/examples/moderator_dashboard/server_subscriber.js @@ -0,0 +1,443 @@ +const fs = require('fs'); +const path = require('path'); + +const got = require('got'); + +const express = require('express'); + +const crypto = require('crypto'); + +/* +Configurations +Note: we don't bother error checking, coz if it errors you got bigger problems +*/ +const config = JSON.parse(fs.readFileSync(path.join( + __dirname, + 'config.json' +))); + +/* +Server +*/ +const app = express(); + +app.set('views', path.join(__dirname, 'views')); +app.set('view engine', 'pug'); +app.locals.basedir = path.join(__dirname, 'views'); + +app.set('view options', { + debug: false, + compileDebug: false +}) + +app.use(express.static(path.join(__dirname, 'public'))); + +/* interfaces */ +const http = require('http').Server(app); +http.listen(config.listen, function() { + console.log('Server raised on', config.listen); +}); + +const redis = require('redis'); +const redis_client = redis.createClient(); +redis_client.on('error', (err) => { + console.error('REDIS Error', err); +}); +//const redis_subscribe = redis.createClient(); +//redis_subscribe.on('error', function (err) { +// console.error('REDIS Error', err); +//}); + +/* +Generate a random string at start up +To secure sessions with +This means when the server restarts, it'll generate a new string +*/ +var secret = crypto.randomBytes(64).toString('hex'); + +/* Session */ +const sess = require('express-session'); +const RedisStore = require('connect-redis')(sess); +// for nginx/nrgok +app.set('trust proxy', 1) // trust first proxy +const session = sess({ + store: new RedisStore({ + client: redis_client + }), + secret, + resave: true, + saveUninitialized: false, + saveUninitialized: true, + cookie: { + secure: true, + secure: false + }, + rolling: true +}); +//cookie: { +// maxAge: (30 * 60 * 1000) +app.use(session); + +/* +Generic Error logger +*/ +app.use((err, req, res, next) => { + //console.log('in here'); + if (err) { + console.log(err); + } + next(err); +}); + +/* +Lets load and setup the streamers authentication keys +*/ +var keystate = false; +function loadKeys() { + redis_client.hget( + config.appname + '_broadcaster', + 'access_token', + (e,access_token) => { + if (e) { + console.error('Redis Problem loading keys', e); + // and crash bigger problems + process.exit(); + } + if (access_token) { + // key exists validate it + got({ + url: 'https://id.twitch.tv/oauth2/validate', + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + access_token + }, + responseType: 'json' + }) + .then(resp => { + // token good + // check age/time left + // 30 minutes + if (resp.body.expires_id <= (30 * 60 * 1000)) { + // regnerate the key + regnerateKey(); + } else { + keystate = access_token; + } + }) + .catch(err => { + if (err.response) { + console.log('Validate error', err.response.statusCode, err.response.body); + } else { + console.log('Bad Validation error', err); + } + // need new keys + }); + } + } + ); +} +loadKeys(); + +function regenerateKey() { + redis_client.hget( + config.appname + '_broadcaster', + 'refresh_token', + (e,refresh_token) => { + if (e) { + console.error('Redis Problem loading keys', e); + // and crash bigger problems + process.exit(); + } + if (!refresh_token) { + console.error('No Refresh Token on file, how did we get here'); + process.exit(); + } + + got({ + url: 'https://id.twitch.tv/oauth2/token', + searchParams: { + grant_type: 'refresh', + refresh_token, + client_id: config.twitch.client_id, + client_secret: config.twitch.client_secret + } + }) + .then(resp => { + storeKeys(resp.body.access_token, resp.body.refresh_token); + }) + .catch(err => { + if (err.response) { + console.log('Refresh error', err.response.statusCode, err.response.body); + } else { + console.log('Bad Refresh error', err); + } + // need new keys + }) + } + ); +} +function storeKeys(access_token, refresh_token, cb) { + // got keys + keystate = access_token; + + // store new keys + redis_client.hmset( + config.appname + '_broadcaster', + 'access_token', + access_token, + 'refresh_token', + refresh_token, + (e,r) => { + if (e) { + console.log('Error Storing new keys', e); + } + + if (cb) { + cb(); + } + } + ); +} + +/* +register some pug globals +*/ +app.use((req, res, next) => { + res.locals.twitch_client_id = config.twitch.client_id; + if (req.session.error) { + res.locals.error = req.session.error; + delete req.session.error; + console.log('Captured error', res.locals.error); + } + if (req.session.auth_error) { + res.locals.auth_error = req.session.auth_error; + delete req.session.auth_error; + console.log('Captured auth error', res.locals.auth_error); + } + + res.locals.user = false; + if (req.session.user) { + res.locals.user = req.session.user; + } + + next(); +}); + +/* +Logout super important +*/ +app.get('/logout', (req,res) => { + req.session.destroy(); + res.redirect('/'); +}); + +/* +Interrupted handler for authentication +*/ +app.use((req,res,next) => { + let { code, error, error_description, scope, state } = req.query; + if (code) { + // first validate the state is valid + state = decodeURIComponent(state); + + console.log(req.session.state, '!=', state); + if (req.session.state != state) { + console.log('state mismatch'); + req.session.auth_error = 'State does not match. Please try again!'; + res.redirect('/'); + return; + } + // done with the state params + delete req.session.state; + + var access_keys = {}; + + // start the oAuth dance + got({ + "url": "https://id.twitch.tv/oauth2/token", + "method": 'POST', + "headers": { + "Accept": "application/json" + }, + "form": { + "client_id": config.twitch.client_id, + "client_secret": config.twitch.client_secret, + code, + "grant_type": "authorization_code", + "redirect_uri": config.twitch.redirect_uri + }, + "responseType": 'json' + }) + .then(resp => { + // oAuth dance success! + access_keys = resp.body; + + // we need to know for whome the access token is for + return got({ + url: 'https://api.twitch.tv/helix/users', + method: 'GET', + headers: { + 'Client-ID': config.twitch.client_id, + 'Authorization': 'Bearer ' + access_keys.access_token + }, + responseType: 'json' + }) + }) + .then(resp => { + if (resp.body && resp.body.data && resp.body.data.length == 1) { + // we got an id + // is it the same ID as the broadcaster + // as the broadcaster is not a moderator on their own channel + req.session.user = resp.body.data[0]; + + if (scope == 'moderation:read') { + // check that it's the configured broadcaster authenticate to prevent take over attacks + if (resp.body.data[0].id != config.twitch.broadcaster_id) { + // wrong broadcaster auth + req.session.auth_error = 'broadcaster Auth for wrong broadcaster'; + res.redirect('/'); + return; + } + + // it's the correct casters key + storeKeys(access_keys.access_token, access_keys.refresh_token, () => { + res.redirect('/'); + }); + } else if (resp.body.data[0].id == config.twitch.broadcaster_id) { + // the broadcaster logged in + req.session.permitted = true; + res.redirect('/'); + } else { + // user loging call moderator check + accessChecks(resp.body.data[0].id, req, res, next); + } + } else { + req.session.error = 'Failed to get your User from Twitch'; + res.redirect('/'); + } + }) + .catch(err => { + if (err.response) { + console.error('Code exchange Error:', err.response.body); + // the oAuth dance failed + req.session.auth_error = 'An Error occured: ' + ((err.response && err.response.body.message) ? err.response.body.message : 'Unknown'); + } else { + req.session.error = 'Code exchange Bad Error', + console.log('Errror', err); + } + res.redirect('/'); + }); + } else if (error) { + req.session.auth_error = 'An Error occured: ' + error_description; + res.redirect('/'); + } else { + next(); + } +}); + +function accessChecks(user_id, req, res, next) { + got({ + url: 'https://api.twitch.tv/helix/subscriptions', + method: 'GET', + headers: { + 'Client-ID': config.twitch.client_id, + 'Authorization': 'Bearer ' + keystate + }, + searchParams: { + broadcaster_id: config.twitch.broadcaster_id, + user_id + }, + responseType: 'json' + }) + .then(resp => { + if (resp.body.data && resp.body.data.length == 1) { + req.session.permitted = true; + req.session.subscriber = true; + } + + return got({ + url: 'https://api.twitch.tv/helix/moderation/moderators', + method: 'GET', + headers: { + 'Client-ID': config.twitch.client_id, + 'Authorization': 'Bearer ' + keystate + }, + searchParams: { + broadcaster_id: config.twitch.broadcaster_id, + user_id + }, + responseType: 'json' + }) + }) + .then(resp => { + if (resp.body.data && resp.body.data.length == 1) { + // yay + req.session.permitted = true; + req.session.moderator = true; + } + }) + .catch(err => { + console.error('Error body:', err.response.body); + // the oAuth dance failed + req.session.error = 'An Error occured: ' + ((err.response && err.response.body.message) ? err.response.body.message : 'Unknown'); + }) + .finally(() => { + if (!req.session.permitted) { + req.session.error = 'You are not a subscriber or moderator, access denied'; + } + res.redirect('/'); + }); +} + +/* +Interrupted handler for caster keys needed +*/ +app.use((req,res,next) => { + // stupidity check + if (req.originalUrl.indexOf('.') >= 0) { + res.status(404); + return; + } + // end stupidity check + + if (!keystate) { + // need broadcaster keys + // interrupt all page loads for the key request + + // setup a nonce/state + req.session.state = crypto.randomBytes(16).toString('base64'); + + // build the URL + var authenticate = 'https://id.twitch.tv/oauth2/authorize' + + '?client_id=' + config.twitch.client_id + + '&redirect_uri=' + encodeURIComponent(config.twitch.redirect_uri) + + '&response_type=code' + + '&scope=moderation:read+channel:read:subscriptions' + + '&state=' + encodeURIComponent(req.session.state); + + res.render('broadcaster_keys_needed', { + authenticate + }); + } else if (!req.session.permitted) { + req.session.state = crypto.randomBytes(16).toString('base64'); + + var authenticate = 'https://id.twitch.tv/oauth2/authorize' + + '?client_id=' + config.twitch.client_id + + '&redirect_uri=' + encodeURIComponent(config.twitch.redirect_uri) + + '&response_type=code' + + '&state=' + encodeURIComponent(req.session.state); + //+ '&scope=' // no scopes we just need userID + // there is an argument to use OIDC here + + // login + res.render('moderator_login', { + authenticate + }); + } else { + next(); + } +}); + +app.get('/', (req,res) => { + res.render('moderator_valid'); +}); diff --git a/examples/moderator_dashboard/views/broadcaster_keys_needed.pug b/examples/moderator_dashboard/views/broadcaster_keys_needed.pug new file mode 100644 index 0000000..cb078e5 --- /dev/null +++ b/examples/moderator_dashboard/views/broadcaster_keys_needed.pug @@ -0,0 +1,18 @@ +html +doctype html +head + title Broadcaster Authentication Needed + link(rel="stylesheet" href="/style.css") +body + h1 Broadcaster Keys needed + + p The Broadcaster Access Keys have died or do not exist + p Please authenticate + + if (error) + p An Error Occured: #{error} + if (auth_error) + p An Auth Error Occured: #{auth_error} + + p + a(href=authenticate) Authenticate Now diff --git a/examples/moderator_dashboard/views/moderator_login.pug b/examples/moderator_dashboard/views/moderator_login.pug new file mode 100644 index 0000000..bc4b501 --- /dev/null +++ b/examples/moderator_dashboard/views/moderator_login.pug @@ -0,0 +1,21 @@ +html +doctype html +head + title Moderator Login + link(rel="stylesheet" href="/style.css") +body + if (user) + a(href="/logout/" style="float: right;") Logout + h1 Moderator Login + + p To Continue please login! + + if (error) + p An Error Occured: #{error} + if (auth_error) + p An Auth Error Occured: #{auth_error} + if (user) + p You logged in as #{user.login} + + p + a(href=authenticate) Authenticate Now diff --git a/examples/moderator_dashboard/views/moderator_valid.pug b/examples/moderator_dashboard/views/moderator_valid.pug new file mode 100644 index 0000000..ee5d141 --- /dev/null +++ b/examples/moderator_dashboard/views/moderator_valid.pug @@ -0,0 +1,18 @@ +html +doctype html +head + title User is valid + link(rel="stylesheet" href="/style.css") +body + if (user) + a(href="/logout/" style="float: right;") Logout + h1 User is Valid + + if (error) + p An Error Occured: #{error} + if (auth_error) + p An Auth Error Occured: #{auth_error} + if (user) + p You logged in as #{user.login} + + p Hello valid moderator! diff --git a/examples/poll_tool/README.md b/examples/poll_tool/README.md new file mode 100644 index 0000000..4c0e592 --- /dev/null +++ b/examples/poll_tool/README.md @@ -0,0 +1,33 @@ +## What is this example + +A simple Poll Tool with which implements EventSub WebSockets for real time results display. +Also supports display of results when using another method to create a poll such as the main Twitch Website/Dashboard + +This is an example on how to + +- Obtain a Token +- Start a poll +- End a Poll +- Show Active Poll Results + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/poll_tool/) + +## Reference Documentation + +- [Create Poll](https://dev.twitch.tv/docs/api/reference#create-poll) +- [End Poll](https://dev.twitch.tv/docs/api/reference#end-poll) +- [Get Polls](https://dev.twitch.tv/docs/api/reference#get-polls) +- [EventSub](https://dev.twitch.tv/docs/eventsub) +- [EventSub RFC](https://discuss.dev.twitch.tv/t/rfc-0016-eventsub-websockets/32652) + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere diff --git a/examples/poll_tool/eventsub.js b/examples/poll_tool/eventsub.js new file mode 100644 index 0000000..570bc0f --- /dev/null +++ b/examples/poll_tool/eventsub.js @@ -0,0 +1,207 @@ +class initSocket { + counter = 0 + closeCodes = { + 4000: 'Internal Server Error', + 4001: 'Client sent inbound traffic', + 4002: 'Client failed ping-pong', + 4003: 'Connection unused', + 4004: 'Reconnect grace time expired', + 4005: 'Network Timeout', + 4006: 'Network error', + 4007: 'Invalid Reconnect' + } + + constructor(connect) { + this._events = {}; + + if (connect) { + this.connect(); + } + } + + connect(url, is_reconnect) { + this.eventsub = {}; + this.counter++; + + url = url ? url : 'wss://eventsub.wss.twitch.tv/ws'; + is_reconnect = is_reconnect ? is_reconnect : false; + + log(`Connecting to ${url}|${is_reconnect}`); + this.eventsub = new WebSocket(url); + this.eventsub.is_reconnecting = is_reconnect; + this.eventsub.counter = this.counter; + + this.eventsub.addEventListener('open', () => { + log(`Opened Connection to Twitch`); + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close_event + // https://github.com/Luka967/websocket-close-codes + this.eventsub.addEventListener('close', (close) => { + console.log('EventSub close', close, this.eventsub); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Connection Closed: ${close.code} Reason - ${this.closeCodes[close.code]}`); + + if (!this.eventsub.is_reconnecting) { + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Is not reconnecting, auto reconnect`); + //new initSocket(); + this.connect(); + } + + if (close.code == 1006) { + // do a single retry + this.eventsub.is_reconnecting = true; + } + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event + this.eventsub.addEventListener('error', (err) => { + console.log(err); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Connection Error`); + }); + // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event + this.eventsub.addEventListener('message', (message) => { + //log('Message'); + console.log(this.eventsub.counter, message); + let { data } = message; + data = JSON.parse(data); + + let { metadata, payload } = data; + let { message_id, message_type, message_timestamp } = metadata; + //log(`Recv ${message_id} - ${message_type}`); + + switch (message_type) { + case 'session_welcome': + let { session } = payload; + let { id, keepalive_timeout_seconds } = session; + + log(`${this.eventsub.counter} This is Socket ID ${id}`); + this.eventsub.twitch_websocket_id = id; + + log(`${this.eventsub.counter} This socket declared silence as ${keepalive_timeout_seconds} seconds`); + + if (!this.eventsub.is_reconnecting) { + log('Dirty disconnect or first spawn'); + this.emit('connected', id); + // now you would spawn your topics + } else { + this.emit('reconnected', id); + // no need to spawn topics as carried over + } + + this.silence(keepalive_timeout_seconds); + + break; + case 'session_keepalive': + //log(`Recv KeepAlive - ${message_type}`); + this.emit('session_keepalive'); + this.silence(); + break; + + case 'notification': + console.log('notification', metadata, payload); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Recv notification ${JSON.stringify(payload)}`); + + let { subscription, event } = payload; + let { type } = subscription; + + this.emit('notification', { metadata, payload }); + this.emit(type, { metadata, payload }); + this.silence(); + + break; + + case 'session_reconnect': + this.eventsub.is_reconnecting = true; + + let reconnect_url = payload.session.reconnect_url; + + console.log('Connect to new url', reconnect_url); + log(`${this.eventsub.twitch_websocket_id}/${this.eventsub.counter} Reconnect request ${reconnect_url}`) + + //this.eventsub.close(); + //new initSocket(reconnect_url, true); + this.connect(reconnect_url, true); + + break; + case 'websocket_disconnect': + log(`${this.eventsub.counter} Recv Disconnect`); + console.log('websocket_disconnect', payload); + + break; + + case 'revocation': + log(`${this.eventsub.counter} Recv Topic Revocation`); + console.log('revocation', payload); + this.emit('revocation', { metadata, payload }); + break; + + default: + console.log(`${this.eventsub.counter} unexpected`, metadata, payload); + break; + } + }); + } + + trigger() { + this.eventsub.send('cat'); + } + close() { + this.eventsub.close(); + } + + silenceHandler = false; + silenceTime = 10;// default per docs is 10 so set that as a good default + silence(keepalive_timeout_seconds) { + if (keepalive_timeout_seconds) { + this.silenceTime = keepalive_timeout_seconds; + this.silenceTime++;// add a little window as it's too anal + } + clearTimeout(this.silenceHandler); + this.silenceHandler = setTimeout(() => { + this.emit('session_silenced');// -> self reconnecting + this.close();// close it and let it self loop + }, (this.silenceTime * 1000)); + } + + on(name, listener) { + if (!this._events[name]) { + this._events[name] = []; + } + + this._events[name].push(listener); + } + emit(name, data) { + if (!this._events[name]) { + return; + } + + const fireCallbacks = (callback) => { + callback(data); + }; + + this._events[name].forEach(fireCallbacks); + } +} + +function log(msg) { + if (!document.getElementById('log')) { + return; + } + + let div = document.createElement('div'); + document.getElementById('log').prepend(div); + + let tim = document.createElement('span'); + div.append(tim); + let t = [ + new Date().getHours(), + new Date().getMinutes(), + new Date().getSeconds() + ] + t.forEach((v,i) => { + t[i] = v < 10 ? '0'+v : v; + }); + tim.textContent = t.join(':'); + + let sp = document.createElement('span'); + div.append(sp); + sp.textContent = msg; +} diff --git a/examples/poll_tool/index.html b/examples/poll_tool/index.html new file mode 100644 index 0000000..af022dd --- /dev/null +++ b/examples/poll_tool/index.html @@ -0,0 +1,78 @@ + + + + Poll Tool | Twitch API Example + + + + +

This example first uses Implicit Auth to get a token to use then will Various endpoints, with the "channel:manage:polls" scope

+ +

Get the code for this example on Github or just View the source instead

+ + Authorize +
Pending
+ +
+
+
+
+ + +

Max 60 Characters

+
+
+ + +

Between 15s and 1800s (30 minutes)

+
+
+ + +

Number of Channel Points required to vote once with Channel Points.

+
+
+
+ Choices +
+ + +
+

Max 25 Characters

+
+
+ + + +
+
+
Last KeepAlive:
+
+
+ + + + + + diff --git a/examples/poll_tool/poll_tool.js b/examples/poll_tool/poll_tool.js new file mode 100644 index 0000000..1ed0842 --- /dev/null +++ b/examples/poll_tool/poll_tool.js @@ -0,0 +1,292 @@ +// go populate this with a client_id +var client_id = 'hozgh446gdilj5knsrsxxz8tahr3koz'; +var redirect = window.location.origin + '/twitch_misc/'; + +// setup a memory space for the token/userID +var access_token = ''; +var user_id = ''; +var poll_id = ''; +var socket_space = false; + +let status = document.getElementById('status'); + +// setup authorise link +document.getElementById('authorize').setAttribute('href', 'https://id.twitch.tv/oauth2/authorize?client_id=' + client_id + '&redirect_uri=' + encodeURIComponent(redirect) + '&response_type=token&scope=channel:manage:polls'); + +async function processToken(token) { + access_token = token; + + status.textContent = 'Got Token. Loading Things'; + + // we need the userID + let user_resp = await fetch( + 'https://api.twitch.tv/helix/users', + { + method: 'GET', + headers: { + 'Client-ID': client_id, + 'Authorization': `Bearer ${access_token}`, + 'Accept': 'application/json' + } + } + ); + + if (user_resp.status != 200) { + status.textContent = `Failed to obtain User information ${user_resp.status} - ${await user_resp.text()}`; + return; + } + + let user_data = await user_resp.json(); + if (user_data.data.length != 1) { + status.textContent = `Failed to obtain a User`; + return; + } + + user_id = user_data.data[0].id; + status.textContent = `Hello ${user_id} - ${user_data.data[0].login}`; + + socket_space = new initSocket(true); + socket_space.on('connected', (id) => { + log(`Connected to WebSocket with ${id}`); + requestHooks(id, user_id); + }); + socket_space.on('session_keepalive', () => { + document.getElementById('keepalive').textContent = new Date(); + }); + + socket_space.on('channel.poll.begin', (msg) => { + writeESLog('begin'); + let { metadata, payload } = msg; + let { event } = payload; + drawNewPoll(event); + }); + socket_space.on('channel.poll.progress', (msg) => { + writeESLog('progress'); + let { metadata, payload } = msg; + let { event } = payload; + drawUpdatePoll(event); + }); + socket_space.on('channel.poll.end', (msg) => { + writeESLog('end'); + let { metadata, payload } = msg; + let { event } = payload; + drawEndPoll(event); + }); +} + +document.getElementById('add_choice').addEventListener('click', (e) => { + let choices = document.getElementById('poll_choices').querySelectorAll('input'); + if (choices.length < 5) { + // add one + let copy = choices[choices.length - 1].cloneNode(); + copy.value = ''; + document.getElementById('poll_choices').append(copy); + } +}); +document.getElementById('poll_form').addEventListener('submit', (e) => { + e.preventDefault(); + + submitPoll(); +}); +document.getElementById('poll_end').addEventListener('click', endPoll); + +async function endPoll() { + let poll_resp = await fetch( + `https://api.twitch.tv/helix/polls`, + { + method: 'PATCH', + headers: { + 'Client-ID': client_id, + 'Authorization': `Bearer ${access_token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + broadcaster_id: user_id, + id: poll_id, + status: 'TERMINATED' + }) + } + ); + + if (poll_resp.status == 200) { + status.textContent = `TERMINATED a poll`; + } else { + status.textContent = `Failed to TERMINATED a poll ${poll_resp.status} - ${await poll_resp.text()}`; + } +} + +async function submitPoll() { + let payload = { + broadcaster_id: user_id, + title: document.getElementById('poll_title').value, + duration: parseInt(document.getElementById('poll_duration').value), + choices: [], + channel_points_voting_enabled: false + } + + let poll_channel_points = parseInt(document.getElementById('poll_channel_points').value); + if (!isNaN(poll_channel_points) && poll_channel_points > 0) { + payload.channel_points_voting_enabled = true; + payload.channel_points_per_vote = poll_channel_points; + } + + let choices = document.getElementById('poll_choices').querySelectorAll('input'); + choices.forEach(choice => { + let text = choice.value.trim(); + + if (text.length > 0) { + payload.choices.push({ + title: text + }); + } + }); + + status.textContent = `Creating a poll`; + + let poll_resp = await fetch( + 'https://api.twitch.tv/helix/polls', + { + method: 'POST', + headers: { + 'Client-ID': client_id, + 'Authorization': `Bearer ${access_token}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(payload) + } + ); + + if (poll_resp.status == 200) { + status.textContent = `Created a poll`; + + // store poll ID so I can kill it + let poll_data = await poll_resp.json(); + poll_id = poll_data.data[0].id; + } else { + status.textContent = `Failed to create a poll ${poll_resp.status} - ${await poll_resp.text()}`; + } +} + + +function requestHooks(session_id, user_id) { + let topics = { + 'channel.poll.begin': { version: 1, condition: { broadcaster_user_id: user_id } }, + 'channel.poll.progress': { version: 1, condition: { broadcaster_user_id: user_id } }, + 'channel.poll.end': { version: 1, condition: { broadcaster_user_id: user_id } }, + } + + log(`Spawn Topics for ${user_id}`); + + for (let type in topics) { + log(`Attempt create ${type} - ${user_id}`); + let { version, condition } = topics[type]; + + fetch( + 'https://api.twitch.tv/helix/eventsub/subscriptions', + { + "method": "POST", + "headers": { + "Client-ID": client_id, + "Authorization": "Bearer " + access_token, + 'Content-Type': 'application/json' + }, + "body": JSON.stringify({ + type, + version, + condition, + transport: { + method: "websocket", + session_id + } + }) + } + ) + .then(resp => resp.json()) + .then(resp => { + if (resp.error) { + log(`Error with eventsub Call ${type} Call: ${resp.message ? resp.message : ''}`); + } else { + log(`Created ${type}`); + } + }) + .catch(err => { + console.log(err); + log(`Error with eventsub Call ${type} Call: ${err.message ? err.message : ''}`); + }); + } +} + + + +function drawNewPoll(poll) { + let { title, choices, started_at, ends_at } = poll; + + let right = document.getElementById('right'); + right.textContent = ''; + + let h2 = document.createElement('h2'); + h2.textContent = title; + right.append(h2); + + let tbl = document.createElement('table'); + right.append(tbl); + + choices.forEach(choice => { + let { id, title } = choice; + + let r = tbl.insertRow(); + let t = r.insertCell(); + t.textContent = title; + let s = r.insertCell(); + s.textContent = 0; + s.setAttribute('id', `poll_choice_${id}`); + }); +} +function drawUpdatePoll(poll) { + let { choices } = poll; + + let total_votes = 0; + choices.forEach(choice => { + let { id, title, channel_points_votes, votes } = choice; + total_votes += channel_points_votes + votes; + + let el = document.getElementById(`poll_choice_${id}`); + el.textContent = (channel_points_votes + votes); + }); +} +function drawEndPoll(poll) { + let { status } = poll; + if (status == "archived") { + return; + } + + drawUpdatePoll(poll); + + let winner = { score: 0, id: '', title: '' }; + + let { choices } = poll; + choices.forEach(choice => { + let { id, title, channel_points_votes, votes } = choice; + let score = channel_points_votes + votes; + if (score > winner.score) { + winner = { + score: score, + id, + title + } + } + }); + + let el = document.getElementById(`poll_choice_${winner.id}`); + if (el) { + el.classList.add('winner'); + } +} + +function writeESLog(evt) { + let l = document.createElement('p'); + l.textContent = `${new Date().toString()} - ${evt}`; + eventsub_log.prepend(l); +} diff --git a/examples/sponsor_finder/index.html b/examples/sponsor_finder/index.html new file mode 100644 index 0000000..8a3b36d --- /dev/null +++ b/examples/sponsor_finder/index.html @@ -0,0 +1,141 @@ + + + + Sponsor Finder| Twitch API Example + + + +

This example first uses Implicit Auth to get a token to use then will return a page similar to The Directorty. Generally calls will be done/cached server side with an App Access Token

+ +

Get the code for this example on Github or just View the source instead

+ + Authorize +
+ + + + + +
+ + + + diff --git a/examples/stream_key/README.md b/examples/stream_key/README.md new file mode 100644 index 0000000..974c9dd --- /dev/null +++ b/examples/stream_key/README.md @@ -0,0 +1,34 @@ +## What is this example + +This is a very rough example of how to build a page to fetch a user Stream Key/display the stream key to the user logging in. + +It uses Implicit auth to obtain a token, but this is just for the [GitHub pages demo](https://barrycarlyon.github.io/twitch_misc/examples/stream_key/). + +If you are building this yourself and as a "server" application, you'd normally use a [Authorization code grant flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth#authorization-code-grant-flow). To allow you to refetch the StreamKey ad infinitum. + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/stream_key/) + +## Reference Documentation + +- [OAuth Implicit Code Flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth#oauth-implicit-code-flow) +- [Get Streams Key](https://dev.twitch.tv/docs/api/reference#get-stream-key) + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere \ No newline at end of file diff --git a/examples/stream_key/index.html b/examples/stream_key/index.html new file mode 100644 index 0000000..44f3832 --- /dev/null +++ b/examples/stream_key/index.html @@ -0,0 +1,87 @@ + + + + Get My Stream Key | Twitch API Example + + + +

This example first uses Implicit Auth with scope channel:read:stream_key to get a token to use. + +

Get the code for this example on Github or just View the source instead

+ +

After authenticating to get a Key, it calls

+ + + Authorize +
+ + + + diff --git a/examples/tag_counter/index.html b/examples/tag_counter/index.html new file mode 100644 index 0000000..234e136 --- /dev/null +++ b/examples/tag_counter/index.html @@ -0,0 +1,168 @@ + + + + Tag Counter | Twitch API Example + + + + + +

This example first uses Implicit Auth to get a token to use then will return a page similar to The Directorty. Generally calls will be done/cached server side with an App Access Token

+ +

Get the code for this example on Github or just View the source instead

+ + Authorize +
+ +

Top 15 Tags

+

Ignoring most language tags....

+
+

All Tags

+

Unordered

+
+ + + + diff --git a/examples/team/README.md b/examples/team/README.md new file mode 100644 index 0000000..a2c08f0 --- /dev/null +++ b/examples/team/README.md @@ -0,0 +1,58 @@ +## What is this example + +This is an example of using the API to do some things with "Team" data. In this example it's a rough copy of a Team Page from Twitch. + +It uses Implicit auth to obtain a token, but this is just for the [GitHub pages demo](https://barrycarlyon.github.io/twitch_misc/examples/team/). + +If you are building this yourself and as a "server" application, you can use any kind of token as it's all public data. + +## Notes + +This example is a bit long winded as it's designed for fronend/no server usage. + +Normally you would use an App Access Token on a server. +And on that server you would use server side caching on a number of end points. + +Periodically load [The Team](https://dev.twitch.tv/docs/api/reference#get-teams) from the API and store the information about the team and the users currently in the team in a databaes table/cache. + +Periodically load from the cache the users in the team and then load their streams from the [Streams API](https://dev.twitch.tv/docs/api/reference#get-streams). Alternatively since you are buildin a Teams Product, you would use [EventSub](https://dev.twitch.tv/docs/eventsub) and utilise the [Steam Online](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#streamonline), [Steam Offline](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#streamoffline), and [Channel Update](https://dev.twitch.tv/docs/eventsub/eventsub-subscription-types#channelupdate), topics to get updates when they happen instead of polling the API. + +So with EventSub you only poke [Get Teams](https://dev.twitch.tv/docs/api/reference#get-teams) periodically. And add/remove [EventSub Subscriptions](https://dev.twitch.tv/docs/api/reference#create-eventsub-subscription) as needed. + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/team/) + +## Reference Documentation + +- [Embed Everything](https://dev.twitch.tv/docs/embed/everything) +- [Player JS API](https://dev.twitch.tv/docs/embed/video-and-clips#interactive-frames-for-live-streams-and-vods) +- [Get Team Information](https://dev.twitch.tv/docs/api/reference#get-teams) +- [Get Streams](https://dev.twitch.tv/docs/api/reference#get-streams) +- [Get Channel Information](https://dev.twitch.tv/docs/api/reference#get-channel-information) +- [Get Users (for user Lookup)](https://dev.twitch.tv/docs/api/reference#get-users) +- [Get Games (for game Boxart)](https://dev.twitch.tv/docs/api/reference#get-games) + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere + +## Screenshot + +This example will first load the "Loaded" team. Left is the ["Twitch" layout](https://www.twitch.tv/team/loadedllc) and right is the [output this example generates](https://barrycarlyon.github.io/twitch_misc/examples/team/). + +![Example](example.png) diff --git a/examples/team/example.png b/examples/team/example.png new file mode 100644 index 0000000..64d1325 Binary files /dev/null and b/examples/team/example.png differ diff --git a/examples/team/index.html b/examples/team/index.html new file mode 100644 index 0000000..2b832ce --- /dev/null +++ b/examples/team/index.html @@ -0,0 +1,750 @@ + + + + Teams Page | Twitch API Example + + + + + +

This example first uses Implicit Auth to get a token to use then will Various endpoints. Generally calls will be done/cached server side with an App Access Token

+ +

Get the code for this example on Github or just View the source instead

+ +

Teams to try + + + + + +
Different Team ValueTeam Name
7311Twitch Ambassadors
loadedllcLoaded
thsThe Hammer Squad
+ + Authorize +

+
+ +
+
+ +
Pending
+ +
+
+
+ +
+
+

Team Members

+
+
+
+ +
+
+ + + + diff --git a/examples/token_checker/README.md b/examples/token_checker/README.md new file mode 100644 index 0000000..3890510 --- /dev/null +++ b/examples/token_checker/README.md @@ -0,0 +1,30 @@ +## What is this example + +A simple Token Validator + +This is an example on how to + +- Validate a Token +- Check the tokens type +- Get some user data about the token if it's a user token +- Revoke that token + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/token_checker/) + +## Reference Documentation + +- [Validate a request](https://dev.twitch.tv/docs/authentication/validate-tokens/) +- [Obtaining a User by Access Token](https://dev.twitch.tv/docs/api/reference#get-users) +- [Revoking a token](https://dev.twitch.tv/docs/authentication/revoke-tokens/) + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere diff --git a/examples/token_checker/index.html b/examples/token_checker/index.html new file mode 100644 index 0000000..1d94d0d --- /dev/null +++ b/examples/token_checker/index.html @@ -0,0 +1,187 @@ + + + + Token Checker | Twitch API Example + + + +

This example demonstrates how to Check a token for it's type, what ClientID generated it, what scopes it has, whose token it is and lets you revoke the token

+ +

Get the code for this example on Github or just View the source instead

+ +

APIs Used

+ + +
+
+ + + + +
+
+ +

Output

+
+ + + + diff --git a/examples/twitchemotes/README.md b/examples/twitchemotes/README.md new file mode 100644 index 0000000..46e5e5d --- /dev/null +++ b/examples/twitchemotes/README.md @@ -0,0 +1,41 @@ +## What is this example + +This is a tool for looking at a Channels Art. Such as Emotes and Badges. + +It uses Implicit auth to obtain a token, generally for a Server Side Solution you'd use an App Access/Client Credentials Token + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/twitchemotes/) + +## Reference Documentation + + +- [Get Users](https://dev.twitch.tv/docs/api/reference#get-users) - We need the User ID to call other endpoints +- [Get Channel Chat Badges](https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges) +- [Get Channel Emotes](https://dev.twitch.tv/docs/api/reference/#get-channel-emotes) + +## But what about rate limits? + +This example runs in a browser and we are using implicit auth to get a token to use. +As a result we are using frontend JS to make the API calls, and browsers will limit the number of requests made to the same domain (api.twitch.tv in this example), so we can't "hammer" enough to get close to the rate limit. + +But that is something to consider if you are making these calls server side to many users. + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere diff --git a/examples/twitchemotes/index.html b/examples/twitchemotes/index.html new file mode 100644 index 0000000..11e4157 --- /dev/null +++ b/examples/twitchemotes/index.html @@ -0,0 +1,336 @@ + + + + TwitchEmotes | Twitch API Example + + + + +

Twitch Emotes

+

We need a token to work with so please Authorize to continue

+ + Authorize +
Please Login with Twitch
+ +
+
+
+
+
+ Select a Channel +
+ + +
+
+
+
+ +
+
+
+
+
+
+ Select a Channel to Compare +
+ + +
+
+
+
+ +
+
+
+ + + + diff --git a/examples/vod_player/README.md b/examples/vod_player/README.md new file mode 100644 index 0000000..e15a0cf --- /dev/null +++ b/examples/vod_player/README.md @@ -0,0 +1,42 @@ +## What is this example + +This is an example of a VOD player that will skip muted segments, now that the Get Videos API returns this data + +It uses Implicit auth to obtain a token, but this is just for the [GitHub pages demo](https://barrycarlyon.github.io/twitch_misc/examples/vod_player/). + +If you are building this yourself and as a "server" application, you can use any kind of token as it's all public data. + +## TRY THIS EXAMPLE NOW! + +This example is also available via GitHub Pages! + +Give it a [whirl here](https://barrycarlyon.github.io/twitch_misc/examples/vod_player/) + +## Reference Documentation + +- [Embed Everything](https://dev.twitch.tv/docs/embed/everything) +- [Player JS API](https://dev.twitch.tv/docs/embed/video-and-clips#interactive-frames-for-live-streams-and-vods) +- [Get Videos](https://dev.twitch.tv/docs/api/reference#get-videos) +- [Get users (for user Lookup)](https://dev.twitch.tv/docs/api/reference#get-users) + +## Setting up the config + +- Visit [Twitch Dev Console](https://dev.twitch.tv/console/) +- Visit Applications +- Manage your Application, or create one if you don't have one +- Copy the Client ID into `client_id` JavaScript Variable +- You'll need to throw this webpage into a website somewhere, and update the `redirect` in the html file and on the dev console accordingly. + +## Running the example + +If you have PHP installed + +> sudo php -S 127.0.0.1:80 + +or just throw the code up on a webpage somewhere + +## Screenshot + +This shows a recent vod from GabrielAndDresden where the player (now paused for screenshot purposes) has skipped the first segment that is muted + +![Example](example.png) diff --git a/examples/vod_player/example.png b/examples/vod_player/example.png new file mode 100644 index 0000000..3ffa52c Binary files /dev/null and b/examples/vod_player/example.png differ diff --git a/examples/vod_player/index.html b/examples/vod_player/index.html new file mode 100644 index 0000000..510dda4 --- /dev/null +++ b/examples/vod_player/index.html @@ -0,0 +1,461 @@ + + + + Vod Player | Twitch API Example + + + + + +

This example first uses Implicit Auth to get a token to use then will create a Player that will play a Vod from GabrielAndDresden that will skip muted segments. You could also look up a Different Channel

+ +

This example uses the following API's Embed Everything, Player JS API, Get Videos, Get users (for user Lookup). Get the code for this example on Github or just View the source instead

+ + Authorize + +
+
+ +
+
+
YOU MAY NEED TO MANUALLY UNMUTE THE PLAYER WHEN HITTING PLAY ON A VIDEO for the first time, yay browser interaction score
+
+ +
+
+
+
+
+
+
+
+
+ + + +