first commit 🎉

This commit is contained in:
mathieudutour 2020-01-07 18:19:26 +01:00
commit 745bd5fc89
33 changed files with 22813 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*/.env
# package directories
node_modules
# Mac files
.DS_Store

32
README.md Normal file
View File

@ -0,0 +1,32 @@
<div align="center">
<img alt="GitHub Sponsors + streamlabs" src="./frontend/static/banner.png" />
</div>
<br />
<div align="center">
<strong>Streaming alerts for GitHub Sponsors</strong>
</div>
_Trigger real-time [streamlabs](https://streamlabs.com/) subscription alerts when someone sponsors you on [GitHub](https://github.com/sponsors)._
## :link: Live Website
Here's the [live website](https://streamlabs-github-sponsors.netlify.com).
## :pencil2: Contributing
Any idea on how to make the process easier? [Open a new issue](https://github.com/mathieudutour/streamlabs-github-sponsors-alerts/issues/new)! We need all the help we can get to make this project awesome!
## :shell: Technical stack
This project is only possible thanks to the awesomeness of the following projects:
- [Gatsby](https://www.gatsbyjs.org/)
- [Serverless](https://www.serverless.com/)
- [AWS Lambda](https://aws.amazon.com/)
- [Netlify](https://netlify.com)
- [streamlabs](https://streamlabs.com/)
- [GitHub Sponsors](https://github.com/sponsors)
## :tm: License
MIT

9
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# Serverless directories
.serverless
# Webpack directories
.webpack
# secrets
config.*.yml
github-app.*.pem

View File

@ -0,0 +1,3 @@
# https://streamlabs.com/dashboard#/apps
STREAMLABS_CLIENT_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
STREAMLABS_CLIENT_SECRET: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

56
backend/lib/_handler.ts Normal file
View File

@ -0,0 +1,56 @@
import { APIGatewayProxyEvent, APIGatewayProxyHandler } from 'aws-lambda'
import { User } from './model'
import { findUserByToken } from './storage'
import { Unauthorized } from './errors'
export const _handler = (
fn: (event: APIGatewayProxyEvent) => Promise<any>
): APIGatewayProxyHandler => async (event: APIGatewayProxyEvent) => {
try {
const result = (await fn(event)) || {}
return {
statusCode: result.statusCode || 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': true,
...(result.headers || {}),
},
body: result.isBase64Encoded ? result.result : JSON.stringify(result),
isBase64Encoded: result.isBase64Encoded,
}
} catch (err) {
console.error(err)
return {
statusCode: err.statusCode || 500,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': true,
},
body: JSON.stringify({
message: err.message,
data: err.data,
}),
}
}
}
export const _withAuth = (
fn: (event: APIGatewayProxyEvent, user: User) => Promise<any>
): ((event: APIGatewayProxyEvent) => Promise<any>) => async (
event: APIGatewayProxyEvent
) => {
if (!event.headers || !event.headers.Authorization) {
throw new Unauthorized('Missing "Authorization" header.')
}
const token = event.headers.Authorization.replace('Bearer ', '')
const user = await findUserByToken(token)
if (!user) {
throw new Unauthorized('No valid API key provided.')
}
return await fn(event, user)
}

19
backend/lib/errors.ts Normal file
View File

@ -0,0 +1,19 @@
export class BadRequest extends Error {
statusCode = 400
data = null
}
export class NotFound extends Error {
statusCode = 404
data = null
}
export class Unauthorized extends Error {
statusCode = 401
data = null
}
export class Forbidden extends Error {
statusCode = 403
data = null
}

View File

@ -0,0 +1 @@
export * from './user'

22
backend/lib/model/user.ts Normal file
View File

@ -0,0 +1,22 @@
export type User = {
id: string // index
username: string
createdAt: number
lastSeenAt: number
// token by the user to make authenticated calls
token: string
// streamlabs OAuth
streamlabsId?: string
streamlabsToken?: string
streamlabsRefreshToken?: string
// alert customization https://dev.streamlabs.com/v1.0/reference#alerts
image_href?: string
sound_href?: string
message?: string
user_message?: string
duration?: string
special_text_color?: string
}

View File

@ -0,0 +1,90 @@
import * as qs from 'querystring'
import fetch from 'node-fetch'
import { User } from '../model'
import { createUser, findUserByStreamlabsId, updateUser } from '../storage'
import { _handler } from '../_handler'
import { BadRequest } from '../errors'
async function authenticate(
code: string
): Promise<{
access_token: string
refresh_token: string
token_type: 'Bearer'
expires_in: number
}> {
const res = await fetch(`https://streamlabs.com/api/v1.0/token`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: qs.stringify({
grant_type: 'authorization_code',
redirect_uri: `${process.env.BASE_API_URL}/oauth/streamlabs`,
client_id: process.env.STREAMLABS_CLIENT_ID,
client_secret: process.env.STREAMLABS_CLIENT_SECRET,
code,
}),
})
const data = await res.json()
if (!res.ok) {
const error = new BadRequest(data.message || data.error_description)
error.statusCode = res.status
throw error
}
return data
}
export const handler = _handler(async event => {
const { access_token, refresh_token } = await authenticate(
event.queryStringParameters.code
)
const res = await fetch(
`https://streamlabs.com/api/v1.0/user?access_token=${access_token}`
)
const data = await res.json()
if (!res.ok) {
const error = new BadRequest(data.message)
error.statusCode = res.status
error.data = data
throw error
}
const streamlabsId = String(data.streamlabs.id)
const existingUser = await findUserByStreamlabsId(streamlabsId)
let user: User
if (existingUser) {
await updateUser(existingUser, {
username: data.streamlabs.display_name,
streamlabsToken: access_token,
streamlabsRefreshToken: refresh_token,
})
user = existingUser
} else {
user = await createUser({
username: data.streamlabs.display_name,
streamlabsId,
streamlabsToken: access_token,
streamlabsRefreshToken: refresh_token,
})
}
const { redirect } = event.pathParameters || {}
return {
statusCode: 301,
headers: {
Location: `https://streamlabs-github-sponsors.netlify.com/oauth-redirect?token=${
user.token
}${redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''}`,
},
}
})

34
backend/lib/rest.ts Normal file
View File

@ -0,0 +1,34 @@
import { _handler, _withAuth } from './_handler'
import { updateUser } from './storage'
export const me = _handler(
_withAuth(async (_, user) => {
return {
id: user.id,
username: user.username,
image_href: user.image_href,
sound_href: user.sound_href,
message: user.message,
user_message: user.user_message,
duration: user.duration,
special_text_color: user.special_text_color,
}
})
)
export const updateMe = _handler(
_withAuth(async (event, user) => {
const body = JSON.parse(event.body)
await updateUser(user, {
image_href: body.image_href,
sound_href: body.sound_href,
message: body.message,
user_message: body.user_message,
duration: body.duration,
special_text_color: body.special_text_color,
})
return {
ok: true,
}
})
)

108
backend/lib/storage.ts Normal file
View File

@ -0,0 +1,108 @@
import * as AWS from 'aws-sdk'
import { generate as randomString } from 'randomstring'
import { User } from './model'
const db = new AWS.DynamoDB.DocumentClient()
const { USERS_TABLE_NAME } = process.env
export const findUser = async (id: string): Promise<User | undefined> => {
if (!id) {
return Promise.resolve(undefined)
}
const meta = await db
.get({
TableName: USERS_TABLE_NAME,
Key: {
id: String(id),
},
})
.promise()
return (meta || { Item: undefined }).Item as User
}
export const findUserByToken = async (
token: string
): Promise<User | undefined> => {
if (!token) {
return Promise.resolve(undefined)
}
const meta = await db
.query({
TableName: USERS_TABLE_NAME,
IndexName: 'tokenIndex',
KeyConditionExpression: '#token = :token',
ExpressionAttributeNames: {
'#token': 'token',
},
ExpressionAttributeValues: { ':token': token },
Limit: 1,
})
.promise()
return (meta || { Items: [] }).Items[0] as User
}
export const findUserByStreamlabsId = async (
streamlabsId: string
): Promise<User | undefined> => {
if (!streamlabsId) {
return Promise.resolve(undefined)
}
const meta = await db
.query({
TableName: USERS_TABLE_NAME,
IndexName: 'streamlabsIdIndex',
KeyConditionExpression: '#streamlabsId = :streamlabsId',
ExpressionAttributeNames: {
'#streamlabsId': 'streamlabsId',
},
ExpressionAttributeValues: { ':streamlabsId': streamlabsId },
Limit: 1,
})
.promise()
return (meta || { Items: [] }).Items[0] as User
}
export const updateUser = async (data: User, body?: Partial<User>) => {
data.lastSeenAt = Date.now()
if (body) {
data = Object.assign(data, body)
Object.keys(body).forEach(k => {
if (typeof body[k] === 'string' && !body[k]) {
delete data[k]
}
})
}
await db
.put({
TableName: USERS_TABLE_NAME,
Item: data,
})
.promise()
return data
}
export const createUser = async (
data: Pick<
User,
'username' | 'streamlabsId' | 'streamlabsToken' | 'streamlabsRefreshToken'
>
) => {
const user: User = {
id: randomString(),
...data,
// generate a random API key
token: randomString(),
createdAt: Date.now(),
lastSeenAt: Date.now(),
}
await db
.put({
TableName: USERS_TABLE_NAME,
Item: user,
})
.promise()
return user
}

View File

@ -0,0 +1,199 @@
import crypto from 'crypto'
import * as qs from 'querystring'
import fetch from 'node-fetch'
import {
_handler
} from '../_handler'
import {
BadRequest
} from '../errors'
import {
findUser
} from '../storage'
function signRequestBody(key: string, body: string) {
return `sha1=${crypto
.createHmac('sha1', key)
.update(body, 'utf8')
.digest('hex')}`
}
function replaceTemplate(string: string, payload: WebhookPayloadSponsorship) {
return string
.replace(/GITHUB_USERNAME/g, payload.sponsorship.sponsor.login)
.replace(/TIER_PRICE/g, payload.sponsorship.tier.monthly_price_in_dollars.toString())
}
type GitHubUser = {
login: string
id: number
node_id: string
avatar_url: string
gravatar_id: string
url: string
html_url: string
followers_url: string
following_url: string
gists_url: string
starred_url: string
subscriptions_url: string
organizations_url: string
repos_url: string
events_url: string
received_events_url: string
type: 'User'
site_admin: false
}
type Tier = {
node_id: string
created_at: string
description: string
monthly_price_in_cents: number
monthly_price_in_dollars: number
name: string
}
type WebhookPayloadSponsorship = {
sponsorship: {
node_id: string
created_at: string
maintainer: GitHubUser
sponsor: GitHubUser
privacy_level: 'public' | 'private'
tier: Tier
}
sender: GitHubUser
} & ( |
{
action: 'created'
} |
{
action: 'edited'
changes: {
privacy_level: 'public' | 'private'
}
} |
{
action: 'tier_changed' // upgrade
changes: {
tier: {
from: Tier
}
}
} |
{
action: 'pending_tier_change' // downgrade
changes: {
tier: {
from: Tier
}
}
effective_date: string
} |
{
action: 'pending_cancellation'
effective_date: string
} |
{
action: 'cancelled'
})
export const handler = _handler(async event => {
const userId = event.pathParameters.userId
if (!userId) {
throw new BadRequest('Missing user id')
}
const user = await findUser(userId)
if (!userId) {
throw new BadRequest('cannot find user')
}
const sig = event.headers['X-Hub-Signature']
const githubEvent = event.headers['X-GitHub-Event']
const id = event.headers['X-GitHub-Delivery']
if (!sig) {
throw new BadRequest('No X-Hub-Signature found on request')
}
if (!githubEvent) {
throw new BadRequest('No X-Github-Event found on request')
}
if (!id) {
throw new BadRequest('No X-Github-Delivery found on request')
}
const calculatedSig = signRequestBody(user.token, event.body)
if (sig !== calculatedSig) {
throw new BadRequest(
"X-Hub-Signature incorrect. Github webhook token doesn't match"
)
}
/* eslint-disable */
console.log('---------------------------------')
console.log(`Github-Event: "${githubEvent}"`)
console.log('---------------------------------')
console.log('Payload', event.body)
/* eslint-enable */
const body: unknown = JSON.parse(event.body || '{}')
// For more on events see https://developer.github.com/v3/activity/events/types/
switch (githubEvent) {
case 'sponsorship': {
const payload = body as WebhookPayloadSponsorship
if (payload.action === 'created' || payload.action === 'tier_changed') {
const data: {[key: string]: string} = {
access_token: user.streamlabsToken,
type: 'subscription',
message: replaceTemplate(user.message || '*GITHUB_USERNAME* just sponsored!', payload)
}
if (user.image_href) {
data.image_href = user.image_href
}
if (user.sound_href) {
data.sound_href = user.sound_href
}
if (user.duration) {
data.duration = user.duration
}
if (user.special_text_color) {
data.special_text_color = user.special_text_color
}
if (user.special_text_color) {
data.special_text_color = user.special_text_color
}
if (user.user_message) {
data.user_message = replaceTemplate(user.user_message, payload)
}
await fetch(`https://streamlabs.com/api/v1.0/alerts`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: qs.stringify(data),
})
}
return { message: 'pong' }
}
default:
return { message: 'pong' }
}
})

4425
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
backend/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "streamlabs-github-sponsors-alerts-backend",
"private": true,
"version": "1.0.0",
"description": "",
"main": "handler.js",
"scripts": {
"lint": "find . -name \"*.ts\" | grep -v -f .gitignore | xargs eslint",
"prettier:base": "prettier --write",
"prettify": "find . -name \"*.ts\" | grep -v -f .gitignore | xargs npm run prettier:base",
"deploy:dev": "serverless deploy -s dev",
"deploy:prod": "serverless deploy -s production",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"node-fetch": "^2.6.0",
"randomstring": "^1.1.5"
},
"devDependencies": {
"@types/aws-lambda": "^8.10.39",
"@types/bcryptjs": "^2.4.2",
"@types/node": "^13.1.4",
"@types/node-fetch": "^2.5.4",
"@types/randomstring": "^1.1.6",
"aws-sdk": "^2.598.0",
"prettier": "^1.19.1",
"serverless-webpack": "^5.3.1",
"source-map-support": "^0.5.16",
"ts-loader": "^6.2.1",
"typescript": "^3.7.4",
"webpack": "^4.41.5"
},
"prettier": {
"proseWrap": "never",
"singleQuote": true,
"trailingComma": "es5",
"semi": false
}
}

102
backend/serverless.yml Normal file
View File

@ -0,0 +1,102 @@
service:
name: streamlabs-github-sponsors
custom:
usersTableName: streamlabs-github-users-${self:provider.stage}
plugins:
- serverless-webpack
provider:
name: aws
runtime: nodejs10.x
region: ${opt:region, 'eu-west-1'}
stage: ${opt:stage, 'dev'}
environment:
BASE_API_URL:
'Fn::Join':
- ''
- - 'https://'
- { 'Ref': 'ApiGatewayRestApi' }
- '.execute-api.${self:provider.region}.amazonaws.com/${self:provider.stage}'
ENV: ${self:provider.stage}
USERS_TABLE_NAME: ${self:custom.usersTableName}
STREAMLABS_CLIENT_ID: ${file(./config.${self:provider.stage}.yml):STREAMLABS_CLIENT_ID}
STREAMLABS_CLIENT_SECRET: ${file(./config.${self:provider.stage}.yml):STREAMLABS_CLIENT_SECRET}
iamRoleStatements:
- Effect: 'Allow'
Action:
- 'dynamodb:*'
Resource:
- arn:aws:dynamodb:*:*:table/${self:custom.usersTableName}
- arn:aws:dynamodb:*:*:table/${self:custom.usersTableName}/*
functions:
streamlabsOauth:
handler: lib/oauth/streamlabs.handler
events:
- http:
method: get
path: oauth/streamlabs
cors: true
me:
handler: lib/rest.me
events:
- http:
method: get
path: me
cors: true
updateMe:
handler: lib/rest.updateMe
events:
- http:
method: put
path: me
cors: true
githubWebhook:
handler: lib/webhook/github.handler
events:
- http:
method: post
path: webhook/github/{userId}
cors: true
resources:
Resources:
UsersDynamoDbTable:
Type: 'AWS::DynamoDB::Table'
DeletionPolicy: Retain
Properties:
AttributeDefinitions:
- AttributeName: id
AttributeType: S
- AttributeName: token
AttributeType: S
- AttributeName: streamlabsId
AttributeType: S
KeySchema:
- AttributeName: id
KeyType: HASH
GlobalSecondaryIndexes:
- IndexName: streamlabsIdIndex
KeySchema:
- AttributeName: streamlabsId
KeyType: HASH
Projection:
ProjectionType: ALL
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
- IndexName: tokenIndex
KeySchema:
- AttributeName: token
KeyType: HASH
Projection:
ProjectionType: ALL
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
ProvisionedThroughput:
ReadCapacityUnits: 1
WriteCapacityUnits: 1
TableName: ${self:custom.usersTableName}

View File

@ -0,0 +1 @@
require('source-map-support').install()

13
backend/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"lib": ["es2017"],
"moduleResolution": "node",
"noUnusedLocals": true,
"noUnusedParameters": true,
"sourceMap": true,
"target": "es2017",
"outDir": "lib",
"allowSyntheticDefaultImports": true
},
"exclude": ["node_modules"]
}

38
backend/webpack.config.js Normal file
View File

@ -0,0 +1,38 @@
const path = require('path')
const slsw = require('serverless-webpack')
const entries = {}
Object.keys(slsw.lib.entries).forEach(key => {
entries[key] = ['./source-map-install.js', slsw.lib.entries[key]]
})
module.exports = {
mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
entry: entries,
devtool: 'source-map',
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
},
output: {
libraryTarget: 'commonjs',
path: path.join(__dirname, '.webpack'),
filename: '[name].js',
},
target: 'node',
module: {
rules: [
// all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
{ test: /\.tsx?$/, loader: 'ts-loader' },
],
},
externals: [
(context, request, callback) => {
if (request === 'aws-sdk') {
return callback(null, `commonjs ${request}`)
}
return callback()
},
],
}

6
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
# dotenv environment variables file
.env
# gatsby files
.cache/
public

37
frontend/gatsby-config.js Normal file
View File

@ -0,0 +1,37 @@
module.exports = {
siteMetadata: {
title: 'Streamlabs GitHub Sponsors',
description: `Trigger subscription alerts when someone sponsors you on GitHub`,
author: `@mathiedutour`,
siteUrl: `https://streamlabs-github-sponsors.netlify.com`,
keywords: `streamlabs, twitch, streaming, alerts, github sponsors, subscriptions`,
},
plugins: [
'gatsby-plugin-react-helmet',
{
resolve: `gatsby-source-filesystem`,
options: {
name: `images`,
path: `${__dirname}/src/images`,
},
},
'gatsby-transformer-sharp',
'gatsby-plugin-sharp',
`gatsby-plugin-sitemap`,
{
resolve: `gatsby-plugin-manifest`,
options: {
name: 'Streamlabs GitHub Sponsors',
short_name: 'Streamlabs GitHub Sponsors',
start_url: '/',
background_color: '#663399',
theme_color: '#663399',
display: 'minimal-ui',
icon: 'src/images/icon.png', // This path is relative to the root of the site.
},
},
`gatsby-plugin-typescript`,
`gatsby-plugin-emotion`,
`gatsby-plugin-netlify`,
],
}

16875
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
frontend/package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "streamlabs-github-sponsors-alerts",
"private": true,
"description": "Trigger subscription alerts when someone sponsors you on GitHub",
"version": "1.0.0",
"author": "Mathieu Dutour",
"dependencies": {
"@emotion/core": "^10.0.27",
"@emotion/styled": "^10.0.27",
"gatsby": "^2.18.18",
"gatsby-image": "^2.2.37",
"gatsby-plugin-emotion": "^4.1.18",
"gatsby-plugin-manifest": "^2.2.34",
"gatsby-plugin-netlify": "^2.1.30",
"gatsby-plugin-react-helmet": "^3.1.18",
"gatsby-plugin-sharp": "^2.3.10",
"gatsby-plugin-sitemap": "^2.2.24",
"gatsby-plugin-typescript": "^2.1.23",
"gatsby-source-filesystem": "^2.1.43",
"gatsby-transformer-sharp": "^2.3.9",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-helmet": "^5.2.1"
},
"keywords": [
"streamlabs",
"twitch",
"streaming",
"alerts",
"github sponsors",
"subscriptions"
],
"license": "MIT",
"scripts": {
"build": "gatsby build",
"develop": "gatsby develop",
"start": "npm run develop",
"clean": "gatsby clean",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@types/react-helmet": "^5.0.15",
"prettier": "^1.19.1"
},
"repository": {
"type": "git",
"url": "https://github.com/mathieudutour/streamlabs-github-sponsors-alerts"
},
"prettier": {
"proseWrap": "never",
"singleQuote": true,
"trailingComma": "es5",
"semi": false
}
}

View File

@ -0,0 +1,336 @@
html {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
article,
aside,
details,
figcaption,
figure,
footer,
header,
main,
menu,
nav,
section,
summary {
display: block;
}
a {
color: #282828;
background-color: transparent;
-webkit-text-decoration-skip: objects;
}
a:active,
a:hover {
outline-width: 0;
}
b,
strong {
font-weight: inherit;
font-weight: bolder;
}
h1 {
font-size: 2em;
margin: 0.67em 0;
}
img {
border-style: none;
}
svg:not(:root) {
overflow: hidden;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
button,
input,
select,
textarea {
font: inherit;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
[type='reset'],
[type='submit'],
button,
html [type='button'] {
-webkit-appearance: button;
}
[type='button']::-moz-focus-inner,
[type='reset']::-moz-focus-inner,
[type='submit']::-moz-focus-inner,
button::-moz-focus-inner {
border-style: none;
padding: 0;
}
[type='button']:-moz-focusring,
[type='reset']:-moz-focusring,
[type='submit']:-moz-focusring,
button:-moz-focusring {
outline: 1px dotted ButtonText;
}
fieldset {
border: 1px solid silver;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
::-webkit-input-placeholder {
color: inherit;
opacity: 0.54;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
html {
font: 112.5%/1.45em sans-serif;
box-sizing: border-box;
overflow-y: scroll;
}
* {
box-sizing: inherit;
}
*:before {
box-sizing: inherit;
}
*:after {
box-sizing: inherit;
}
body {
background: white;
color: #282828;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
font-weight: normal;
word-wrap: break-word;
font-kerning: normal;
-moz-font-feature-settings: 'kern', 'liga', 'clig', 'calt';
-ms-font-feature-settings: 'kern', 'liga', 'clig', 'calt';
-webkit-font-feature-settings: 'kern', 'liga', 'clig', 'calt';
font-feature-settings: 'kern', 'liga', 'clig', 'calt';
}
img {
max-width: 100%;
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
}
h1 {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
color: inherit;
font-weight: bold;
text-rendering: optimizeLegibility;
font-size: 2.25rem;
line-height: 1.1;
}
h2 {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
color: inherit;
font-weight: bold;
text-rendering: optimizeLegibility;
font-size: 1.62671rem;
line-height: 1.1;
}
h3 {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
color: inherit;
font-weight: bold;
text-rendering: optimizeLegibility;
font-size: 1.38316rem;
line-height: 1.1;
}
h4 {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
color: inherit;
font-weight: bold;
text-rendering: optimizeLegibility;
font-size: 1rem;
line-height: 1.1;
}
h5 {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
color: inherit;
font-weight: bold;
text-rendering: optimizeLegibility;
font-size: 0.85028rem;
line-height: 1.1;
}
h6 {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
color: inherit;
font-weight: bold;
text-rendering: optimizeLegibility;
font-size: 0.78405rem;
line-height: 1.1;
}
ul {
margin-left: 1.45rem;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
list-style-position: outside;
list-style-image: none;
}
ol {
margin-left: 1.45rem;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
list-style-position: outside;
list-style-image: none;
}
p {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
}
fieldset {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
}
form {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: 1.45rem;
}
hr {
margin-left: 0;
margin-right: 0;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
padding-right: 0;
padding-top: 0;
margin-bottom: calc(1.45rem - 1px);
background: hsla(0, 0%, 0%, 0.2);
border: none;
height: 1px;
}
li {
margin-bottom: calc(1.45rem / 2);
}
ol li {
padding-left: 0;
}
ul li {
padding-left: 0;
}
li > ol {
margin-left: 1.45rem;
margin-bottom: calc(1.45rem / 2);
margin-top: calc(1.45rem / 2);
}
li > ul {
margin-left: 1.45rem;
margin-bottom: calc(1.45rem / 2);
margin-top: calc(1.45rem / 2);
}
li *:last-child {
margin-bottom: 0;
}
p *:last-child {
margin-bottom: 0;
}
li > p {
margin-bottom: calc(1.45rem / 2);
}
@media only screen and (max-width: 480px) {
html {
font-size: 100%;
}
}

View File

@ -0,0 +1,13 @@
import React from 'react'
import './layout.css'
const Layout = ({ children }) => (
<>
<div>
<main>{children}</main>
</div>
</>
)
export default Layout

View File

@ -0,0 +1,102 @@
/**
* SEO component that queries for data with
* Gatsby's useStaticQuery React hook
*
* See: https://www.gatsbyjs.org/docs/use-static-query/
*/
import React from 'react'
import { Helmet } from 'react-helmet'
import { useStaticQuery, graphql } from 'gatsby'
function SEO({
description,
lang,
meta,
title,
keywords,
}: {
description?: string
lang?: string
meta?: { property: string; content: any; name?: undefined }[]
title?: string
keywords?: string[]
}) {
const { site } = useStaticQuery(
graphql`
query {
site {
siteMetadata {
title
description
author
keywords
}
}
}
`
)
const metaDescription = description || site.siteMetadata.description
return (
<Helmet
htmlAttributes={{
lang: lang || 'en',
}}
title={title || site.siteMetadata.title}
meta={[
{
name: `description`,
content: metaDescription,
},
{
property: `og:title`,
content: title || site.siteMetadata.title,
},
{
property: `og:image`,
content: 'https://streamlabs-github-sponsors.netlify.com/banner.png',
},
{
property: `og:url`,
content: 'https://streamlabs-github-sponsors.netlify.com/',
},
{
property: `og:description`,
content: metaDescription,
},
{
property: `og:type`,
content: `website`,
},
{
name: `twitter:card`,
content: `summary`,
},
{
name: `twitter:creator`,
content: site.siteMetadata.author,
},
{
name: `twitter:title`,
content: title,
},
{
name: `twitter:description`,
content: metaDescription,
},
]
.concat({
name: `keywords`,
content:
(keywords || []).length > 0
? keywords.join(`, `)
: site.siteMetadata.keywords,
})
.concat(meta || [])}
/>
)
}
export default SEO

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -0,0 +1,11 @@
import React from 'react'
import Layout from '../components/layout'
const NotFoundPage = () => (
<Layout>
<h1>NOT FOUND</h1>
<p>You just hit a route that doesn&#39;t exist... the sadness.</p>
</Layout>
)
export default NotFoundPage

View File

@ -0,0 +1,55 @@
import React from 'react'
import styled from '@emotion/styled'
import Layout from '../components/layout'
import SEO from '../components/seo'
const Wrapper = styled.div`
text-align: center;
width: 500px;
max-width: 100%;
padding: 3em 15px;
margin: 0 auto;
`
const ImageWrapper = styled.div`
margin: 3em 0;
max-width: 100%;
`
const LoginButton = styled.a`
display: inline-block;
background: url(shadow.svg);
background-size: 100% 100%;
color: white;
text-decoration: none;
padding: 15px 30px;
margin-top: 3em;
border-radius: 10px;
`
const IndexPage = () => {
return (
<Layout>
<SEO />
<Wrapper>
<ImageWrapper>
<img src="/banner.png" />
</ImageWrapper>
<h1>Streaming alerts for GitHub Sponsors</h1>
<p>
Trigger real-time <a href="https://streamlabs.com/">streamlabs</a>{' '}
subscription alerts when someone sponsors you on{' '}
<a href="https://github.com/sponsors">GitHub</a>.
</p>
<LoginButton
href={`https://www.streamlabs.com/api/v1.0/authorize?client_id=${process.env.GATSBY_STREAMLABS_CLIENT_ID}&redirect_uri=${process.env.GATSBY_BASE_API}/oauth/streamlabs&response_type=code&scope=alerts.create`}
>
Log in with streamlabs
</LoginButton>
</Wrapper>
</Layout>
)
}
export default IndexPage

View File

@ -0,0 +1,108 @@
import React, { useState, useEffect } from 'react'
import { graphql } from 'gatsby'
import Img from 'gatsby-image'
import styled from '@emotion/styled'
import Layout from '../components/layout'
import SEO from '../components/seo'
type User = {
id: string
username: string
// alert customization https://dev.streamlabs.com/v1.0/reference#alerts
image_href: string
sound_href: string
message: string
user_message: string
duration: string
special_text_color: string
}
const Wrapper = styled.div`
width: 800px;
max-width: 100%;
padding: 3em 15px;
margin: 0 auto;
`
const Data = styled.span`
display: inline-block;
background: rgba(0, 0, 0, 0.1);
padding: 3px 8px;
`
const OauthRedirectPage = ({ location }: { location: Location }) => {
const [user, setUser] = useState<User | void>(undefined)
const [githubUsername, setGithubUsername] = useState('')
const token = location.search.replace('?token=', '')
useEffect(() => {
// load the user
fetch(`${process.env.GATSBY_BASE_API}/me`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(res => res.json())
.then(res => setUser(res))
.catch(console.error.bind(console))
}, [])
if (!user) {
return (
<Layout>
<SEO />
<div>Loading...</div>
</Layout>
)
}
return (
<Layout>
<SEO />
<Wrapper>
<h3>Nearly there!</h3>
<div>
One last step: add a GitHub webhook. Sounds complicated? It's not!
Let's walk you through it.
</div>
<ol>
<li>
What's your GitHub username?{' '}
<input
value={githubUsername}
onChange={e => setGithubUsername(e.target.value)}
/>
</li>
<li>
Head over{' '}
<a
href={`https://github.com/sponsors/${githubUsername}/dashboard/webhooks/new`}
target="_blank"
>{`https://github.com/sponsors/${githubUsername}/dashboard/webhooks/new`}</a>
</li>
<li>
Fill the form with the following information:
<ul>
<li>
Payload URL:{' '}
<Data>{`${process.env.GATSBY_BASE_API}/webhook/github/${user.id}`}</Data>
</li>
<li>
Content Type: <Data>application/json</Data>
</li>
<li>
Secret: <Data>{token}</Data>
</li>
</ul>
</li>
<li>
Done! When someone sponsors you on GitHub, you will now receive an
alert on streamlabs.
</li>
</ol>
</Wrapper>
</Layout>
)
}
export default OauthRedirectPage

BIN
frontend/static/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 55">
<g fill="none" fill-rule="evenodd">
<rect x="-10" y="-10" width="260" height="75" fill="#FF2CB1"/>
<ellipse cx="12.142" cy="8.066" fill="#FF89D2" rx="9.5" ry="5" transform="rotate(-21 12.142 8.066)"/>
<path fill="#E50797" d="M0 49.253c102.356 1.95 167.191 1.95 194.507 0 32.109-2.292 34.672 2.2 39.395-49.259 3.6-39.208 8.136-33.658 13.611 16.65 19.857 46.64 19.857 68.862 0 66.666-19.857-2.196-107.216-8.698-262.078-19.507L0 49.253z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 527 B

4
netlify.toml Normal file
View File

@ -0,0 +1,4 @@
[build]
base = "frontend"
publish = "public"
command = "npm run build"