first commit 🎉
This commit is contained in:
commit
745bd5fc89
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,9 @@
|
||||||
|
# Serverless directories
|
||||||
|
.serverless
|
||||||
|
|
||||||
|
# Webpack directories
|
||||||
|
.webpack
|
||||||
|
|
||||||
|
# secrets
|
||||||
|
config.*.yml
|
||||||
|
github-app.*.pem
|
|
@ -0,0 +1,3 @@
|
||||||
|
# https://streamlabs.com/dashboard#/apps
|
||||||
|
STREAMLABS_CLIENT_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
STREAMLABS_CLIENT_SECRET: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './user'
|
|
@ -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
|
||||||
|
}
|
|
@ -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)}` : ''}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
|
@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
|
@ -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
|
||||||
|
}
|
|
@ -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' }
|
||||||
|
}
|
||||||
|
})
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}
|
|
@ -0,0 +1 @@
|
||||||
|
require('source-map-support').install()
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["es2017"],
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"target": "es2017",
|
||||||
|
"outDir": "lib",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
|
@ -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()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# gatsby files
|
||||||
|
.cache/
|
||||||
|
public
|
|
@ -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`,
|
||||||
|
],
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import './layout.css'
|
||||||
|
|
||||||
|
const Layout = ({ children }) => (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Layout
|
|
@ -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 |
|
@ -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't exist... the sadness.</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default NotFoundPage
|
|
@ -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
|
|
@ -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
|
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
|
@ -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 |
|
@ -0,0 +1,4 @@
|
||||||
|
[build]
|
||||||
|
base = "frontend"
|
||||||
|
publish = "public"
|
||||||
|
command = "npm run build"
|
Loading…
Reference in New Issue