mirror of
https://github.com/neongamerbot-qk/hackclub-nest
synced 2024-11-22 08:23:40 +00:00
Compare commits
26 commits
170c4c4b44
...
c9e1a809a0
Author | SHA1 | Date | |
---|---|---|---|
|
c9e1a809a0 | ||
|
7ae3e7b097 | ||
|
6ff90c7dbc | ||
cefad57bb7 | |||
16b91340e8 | |||
8af5137c50 | |||
4785c361af | |||
025faf5f79 | |||
ed4e6a5fe0 | |||
|
15ae64f5af | ||
|
7aa7762a94 | ||
|
274d77687b | ||
|
80ddb0bf81 | ||
|
e44e36f01b | ||
|
cae7a50ffc | ||
|
4b4460b840 | ||
f1eaf98e84 | |||
|
1282743295 | ||
|
8c24a2760f | ||
|
7ef5256e63 | ||
|
4969b4941d | ||
|
98b3cf258b | ||
|
a8409d6bf4 | ||
|
1cf2d561a4 | ||
|
38c3ed1967 | ||
|
aa73856b49 |
19 changed files with 2900 additions and 33 deletions
|
@ -1,20 +0,0 @@
|
||||||
cat .config/systemd/user/caddy.service
|
|
||||||
screen
|
|
||||||
htop
|
|
||||||
free -m -h
|
|
||||||
quota
|
|
||||||
quota -h
|
|
||||||
ls
|
|
||||||
du -h
|
|
||||||
neofetch
|
|
||||||
ls -a
|
|
||||||
htop
|
|
||||||
free -m -h
|
|
||||||
ls
|
|
||||||
nest resources
|
|
||||||
ls
|
|
||||||
mutt
|
|
||||||
ls Mail/
|
|
||||||
mutt
|
|
||||||
ls -a
|
|
||||||
exit
|
|
|
@ -1 +1,60 @@
|
||||||
{"admin":{"listen":"unix//home/neon/caddy-admin.sock"},"apps":{"http":{"servers":{"srv0":{"automatic_https":{"skip":["neon.hackclub.app"]},"listen":["unix/.webserver.sock|777"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"vars","root":"/home/neon/pub"},{"handler":"file_server","hide":[".git",".env","/home/neon/Caddyfile"]}]}]}],"match":[{"host":["neon.hackclub.app"]}],"terminal":true}]}}}}}
|
{
|
||||||
|
"admin": { "listen": "unix//home/neon/caddy-admin.sock" },
|
||||||
|
"apps": {
|
||||||
|
"http": {
|
||||||
|
"servers": {
|
||||||
|
"srv0": {
|
||||||
|
"automatic_https": { "skip": ["spotify.hackclub.app"] },
|
||||||
|
"listen": ["unix/.spotify.neon.hackclub.app.webserver.sock|777"],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"handle": [
|
||||||
|
{
|
||||||
|
"handler": "subroute",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"handle": [
|
||||||
|
{
|
||||||
|
"handler": "reverse_proxy",
|
||||||
|
"upstreams": [{ "dial": "localhost:38275" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"match": [{ "host": ["spotify.hackclub.app"] }],
|
||||||
|
"terminal": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"srv1": {
|
||||||
|
"automatic_https": { "skip": ["neon.hackclub.app"] },
|
||||||
|
"listen": ["unix/.webserver.sock|777"],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"handle": [
|
||||||
|
{
|
||||||
|
"handler": "subroute",
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"handle": [
|
||||||
|
{ "handler": "vars", "root": "/home/neon/pub" },
|
||||||
|
{
|
||||||
|
"handler": "file_server",
|
||||||
|
"hide": [".git", ".env", "/home/neon/Caddyfile"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"match": [{ "host": ["neon.hackclub.app"] }],
|
||||||
|
"terminal": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
9
.gitignore
vendored
9
.gitignore
vendored
|
@ -1,8 +1,12 @@
|
||||||
*.sock
|
*.sock
|
||||||
|
.npm
|
||||||
.config/git
|
.config/git
|
||||||
.config/pulse # tokens
|
.config/pulse # tokens
|
||||||
Mail # mail sent to neon@hackclub.app is here
|
.config/caddy
|
||||||
sent # all sent mail is there
|
Mail
|
||||||
|
sent
|
||||||
|
Mail/* # mail sent to neon@hackclub.app is here
|
||||||
|
sent/* # all sent mail is there
|
||||||
.bun
|
.bun
|
||||||
.local
|
.local
|
||||||
.nix-profile
|
.nix-profile
|
||||||
|
@ -14,5 +18,6 @@ sent # all sent mail is there
|
||||||
.cache/*
|
.cache/*
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
!.env.example
|
||||||
.uptime-url
|
.uptime-url
|
||||||
main.cron
|
main.cron
|
||||||
|
|
1
.lesshst
Normal file
1
.lesshst
Normal file
|
@ -0,0 +1 @@
|
||||||
|
.less-history-file:
|
|
@ -9,3 +9,7 @@ http://neon.hackclub.app {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
http://spotify.neon.hackclub.app {
|
||||||
|
bind unix/.spotify.sock|777
|
||||||
|
reverse_proxy localhost:37935
|
||||||
|
}
|
||||||
|
|
1
Mail/.gitignore
vendored
Normal file
1
Mail/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
*
|
7
hackclub-spotify-bot/.env.example
Normal file
7
hackclub-spotify-bot/.env.example
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
SLACK_CLIENT_ID=
|
||||||
|
SLACK_CLIENT_SECRET=
|
||||||
|
SLACK_TOKEN=
|
||||||
|
SPOTIFY_CLIENT_ID=
|
||||||
|
SPOTIFY_CLIENT_SECRET=
|
||||||
|
SPOTIFY_REDIRECT_URI=
|
||||||
|
SLACK_REDIRECT_URI=
|
4
hackclub-spotify-bot/.gitignore
vendored
Normal file
4
hackclub-spotify-bot/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
db.json
|
||||||
|
data/*
|
36
hackclub-spotify-bot/README.md
Normal file
36
hackclub-spotify-bot/README.md
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
## Hackclub Community playlist
|
||||||
|
|
||||||
|
This is for the hackclub community to have a public playlist that they can contribute to.
|
||||||
|
|
||||||
|
Slack bot manifest:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"display_information": {
|
||||||
|
"name": "Hackclub Spotify",
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"bot_user": {
|
||||||
|
"display_name": "Hackclub Spotify",
|
||||||
|
"always_online": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_config": {
|
||||||
|
"redirect_urls": [
|
||||||
|
"https://<domain>/slack/callback"
|
||||||
|
],
|
||||||
|
"scopes": {
|
||||||
|
"user": [
|
||||||
|
"identity.avatar",
|
||||||
|
"identity.basic",
|
||||||
|
"identity.team"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"org_deploy_enabled": false,
|
||||||
|
"socket_mode_enabled": false,
|
||||||
|
"token_rotation_enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
15
hackclub-spotify-bot/TODO.md
Normal file
15
hackclub-spotify-bot/TODO.md
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
- [x] Slack stuff
|
||||||
|
- - ~~[ ] web api [docs](https://tools.slack.dev/node-slack-sdk/web-api/)~~
|
||||||
|
- - [x] send messages thru zeon https://github.com/NeonGamerBot-QK/slack-zeon/blob/092d324c7c58d37e2165ecf0a6798a983c75e7d2/src/modules/slackapp.ts#L19-L53
|
||||||
|
- - [x] oauth [docs](https://tools.slack.dev/node-slack-sdk/oauth)
|
||||||
|
- - [x] fix the [channel](https://app.slack.com/client/T0266FRGM/C07RE4N7S4B)
|
||||||
|
- - - [x] Add ping for new-song event & send message ovs
|
||||||
|
- [x] Spotify
|
||||||
|
- - [x] web api [docs](https://developer.spotify.com/documentation/web-api/)
|
||||||
|
- - [x] oauth [docs](https://developer.spotify.com/documentation/general/guides/authorization-guide/)
|
||||||
|
- - [x] refresh token [docs](https://developer.spotify.com/documentation/general/guides/authorization-guide/#refresh-an-access-token)
|
||||||
|
- - [x] playlist tools (creation,modifcation,deletion)
|
||||||
|
- [x] keydb (quick db or smthing)
|
||||||
|
- [x] express
|
||||||
|
- [x] transparency of added songs
|
||||||
|
- - [x] export db -> into csv with properties (slack_id, url, added_at)
|
23
hackclub-spotify-bot/package.json
Normal file
23
hackclub-spotify-bot/package.json
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "hackclub-spotify-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@slack/oauth": "^3.0.1",
|
||||||
|
"better-sqlite3": "^11.3.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"quick.db": "^9.1.7",
|
||||||
|
"session-file-store": "^1.5.0",
|
||||||
|
"spotify-uri": "^4.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"express-status-monitor": "^1.3.4"
|
||||||
|
}
|
||||||
|
}
|
266
hackclub-spotify-bot/src/index.js
Normal file
266
hackclub-spotify-bot/src/index.js
Normal file
|
@ -0,0 +1,266 @@
|
||||||
|
const path = require("path");
|
||||||
|
require("dotenv").config();
|
||||||
|
const express = require("express");
|
||||||
|
const session = require("express-session");
|
||||||
|
const FileStore = require("session-file-store")(session);
|
||||||
|
const { InstallProvider, FileInstallationStore } = require("@slack/oauth");
|
||||||
|
const {
|
||||||
|
getLoginUrl,
|
||||||
|
refreshToken,
|
||||||
|
getCredentials,
|
||||||
|
saveCredentials,
|
||||||
|
spotifyRoutes,
|
||||||
|
addSongToPlaylist,
|
||||||
|
} = require("./spotify");
|
||||||
|
const { QuickDB } = require("quick.db");
|
||||||
|
|
||||||
|
const db = new QuickDB({
|
||||||
|
filePath: "./data/songs.sqlite",
|
||||||
|
});
|
||||||
|
|
||||||
|
function arrayToCsv(data) {
|
||||||
|
return data
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
row
|
||||||
|
.map(String) // convert every value to String
|
||||||
|
.map((v) => v.replaceAll('"', '""')) // escape double quotes
|
||||||
|
.map((v) => `"${v}"`) // quote it
|
||||||
|
.join(","), // comma-separated
|
||||||
|
)
|
||||||
|
.join("\r\n"); // rows starting on new lines
|
||||||
|
}
|
||||||
|
|
||||||
|
let cacheDb = {};
|
||||||
|
const app = express();
|
||||||
|
const userScopes = ["identity.avatar", "identity.basic", "identity.team"];
|
||||||
|
// Initialize
|
||||||
|
const oauth = new InstallProvider({
|
||||||
|
clientId: process.env.SLACK_CLIENT_ID,
|
||||||
|
clientSecret: process.env.SLACK_CLIENT_SECRET,
|
||||||
|
stateSecret: process.env.STATE_SECRET,
|
||||||
|
stateVerification: false,
|
||||||
|
stateStore: new FileInstallationStore(
|
||||||
|
path.join(__dirname, "../data/states.json"),
|
||||||
|
),
|
||||||
|
installationStore: new FileInstallationStore(
|
||||||
|
path.join(__dirname, "../data/installations.json"),
|
||||||
|
),
|
||||||
|
// installationStore: {
|
||||||
|
|
||||||
|
//}
|
||||||
|
stateStore: {
|
||||||
|
generateStateParam: (installUrlOptions, date) => {
|
||||||
|
// generate a random string to use as state in the URL
|
||||||
|
const randomState =
|
||||||
|
process.env.STATE_SECRET + Math.random().toString(36).substring(7);
|
||||||
|
// save installOptions to cache/db
|
||||||
|
cacheDb[randomState] = installUrlOptions;
|
||||||
|
// myDB.set(randomState, installUrlOptions);
|
||||||
|
// return a state string that references saved options in DB
|
||||||
|
return randomState;
|
||||||
|
},
|
||||||
|
// verifyStateParam's first argument is a date object and the second argument is a string representing the state
|
||||||
|
// verifyStateParam is expected to return an object representing installUrlOptions
|
||||||
|
verifyStateParam: (date, state) => {
|
||||||
|
return cacheDb[state];
|
||||||
|
// fetch saved installOptions from DB using state reference
|
||||||
|
const installUrlOptions = myDB.get(randomState);
|
||||||
|
return installUrlOptions;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
app.set("view engine", "ejs");
|
||||||
|
app.set("views", "src/views");
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
secret: process.env.STATE_SECRET,
|
||||||
|
resave: true,
|
||||||
|
store: new FileStore({
|
||||||
|
path: path.join(__dirname, "../data/sessions"),
|
||||||
|
}),
|
||||||
|
saveUninitialized: true,
|
||||||
|
cookie: { secure: "auto", maxAge: 1000 * 60 * 60 * 24 * 7 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusMonitor = require("express-status-monitor")({
|
||||||
|
healthChecks: [
|
||||||
|
{
|
||||||
|
protocol: "http",
|
||||||
|
host: "localhost",
|
||||||
|
port: 3000,
|
||||||
|
path: "/",
|
||||||
|
timeout: 1000,
|
||||||
|
interval: 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
app.use(statusMonitor);
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
// console.debug([req.headers, req.session])
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// we can ignore since this is an optional dependency
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get("/login", async (req, res) => {
|
||||||
|
if (req.session.token) {
|
||||||
|
res.redirect("/home");
|
||||||
|
} else {
|
||||||
|
res.redirect(
|
||||||
|
await oauth.generateInstallUrl({
|
||||||
|
// Add the scopes your app needs
|
||||||
|
redirectUri: process.env.SLACK_REDIRECT_URI,
|
||||||
|
scopes: [],
|
||||||
|
|
||||||
|
userScopes: userScopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.get("/slack/callback", (req, res) => {
|
||||||
|
// console.debug(req.headers, req.url)
|
||||||
|
oauth.handleCallback(req, res, {
|
||||||
|
success: async (install) => {
|
||||||
|
// typings
|
||||||
|
// user: { token:string , scopes: string[], id: string}
|
||||||
|
// console.log(install)
|
||||||
|
req.session.info = install;
|
||||||
|
req.session.token = install.user.token;
|
||||||
|
res.redirect("/home");
|
||||||
|
},
|
||||||
|
failure: (err) => {
|
||||||
|
console.log(err);
|
||||||
|
res.send(
|
||||||
|
"Failed to install!, please contact neon in the slack!, \n" + err.stack,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.get("/logout", (req, res) => {
|
||||||
|
req.session.destroy();
|
||||||
|
res.redirect("/");
|
||||||
|
});
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.render("index", {
|
||||||
|
title: "Hack Club Spotify Bot",
|
||||||
|
description: "Contribute to the hackclub spotify playlist!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const errorStrings = [
|
||||||
|
"Invalid CSRF Token!", // token = csrf token
|
||||||
|
"Song is not a track! (or not even a spotify song url)",
|
||||||
|
"Song already exists in the database! (its in the playlist or banned from the playlist)",
|
||||||
|
];
|
||||||
|
app.get("/home", async (req, res) => {
|
||||||
|
if (!req.session.info) return res.redirect("/login");
|
||||||
|
let onetimetoken = Math.random().toString(36).substring(7);
|
||||||
|
cacheDb[onetimetoken] = true;
|
||||||
|
res.render("home", {
|
||||||
|
title: "Hack Club Spotify Bot",
|
||||||
|
description: "Contribute to the hackclub spotify playlist!",
|
||||||
|
userinfo: req.session.info,
|
||||||
|
onetimetoken,
|
||||||
|
error: errorStrings[req.query.error],
|
||||||
|
s: req.query.s,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.get("/download/db", async (req, res) => {
|
||||||
|
if (!req.session.info) return res.redirect("/login");
|
||||||
|
const allSongs = await db.all();
|
||||||
|
const csvData = arrayToCsv([
|
||||||
|
["slack_id", "url", "song_id", "added_at"],
|
||||||
|
...allSongs.map((d) => {
|
||||||
|
return [d.value.added_by, d.value.song_url, d.id, d.value.added_at];
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
res.setHeader("Content-Type", "text/csv");
|
||||||
|
res.setHeader("Content-Disposition", 'attachment; filename="songs.csv"');
|
||||||
|
res.send(csvData);
|
||||||
|
});
|
||||||
|
app.post("/spotify/submitsong", async (req, res) => {
|
||||||
|
if (!req.session.token) return res.redirect("/login");
|
||||||
|
if (!cacheDb[req.query.token]) return res.redirect(`/home?error=0`);
|
||||||
|
delete cacheDb[req.query.token];
|
||||||
|
|
||||||
|
const songurl = req.body.songurl;
|
||||||
|
|
||||||
|
const songuriinfo = require("spotify-uri").parse(songurl);
|
||||||
|
if (songuriinfo.type !== "track") return res.redirect(`/home?error=1`);
|
||||||
|
const alreadyExists = await db.has(songuriinfo.id);
|
||||||
|
if (alreadyExists) return res.redirect(`/home?error=2`);
|
||||||
|
const formattedURI = require("spotify-uri").formatURI(songuriinfo);
|
||||||
|
await db.set(songuriinfo.id, {
|
||||||
|
song_url: songurl,
|
||||||
|
added_by: req.session.info.user.id,
|
||||||
|
added_at: Date.now(),
|
||||||
|
});
|
||||||
|
addSongToPlaylist(formattedURI);
|
||||||
|
fetch("https://slack.mybot.saahild.com/send-private", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
channel: "C07RE4N7S4B",
|
||||||
|
text: `:new_spotify: New Song: ${songurl} - added by <@${req.session.info.user.id}>`,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
Authorization: process.env.AUTH_FOR_ZEON,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
fetch("https://slack.mybot.saahild.com/send-private", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
channel: "C07RE4N7S4B",
|
||||||
|
thread_ts: d.ts,
|
||||||
|
text: `:thread: Responses about new song here please!`,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
Authorization: process.env.AUTH_FOR_ZEON,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (!process.env.TESTING) {
|
||||||
|
fetch("https://slack.mybot.saahild.com/send-private", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
channel: "C07RE4N7S4B",
|
||||||
|
text: `<!subteam^S07RGTY93J8>`,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
Authorization: process.env.AUTH_FOR_ZEON,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.redirect("/home?s=1");
|
||||||
|
});
|
||||||
|
app.get("/spotify/link", async (req, res) => {
|
||||||
|
if (!req.session.info) return res.redirect("/login");
|
||||||
|
if (req.session.info.user.id !== "U07L45W79E1")
|
||||||
|
return res.status(401).end("unauthorized");
|
||||||
|
res.redirect(getLoginUrl());
|
||||||
|
});
|
||||||
|
|
||||||
|
spotifyRoutes(app);
|
||||||
|
|
||||||
|
app.listen(process.env.PORT || 3000, async () => {
|
||||||
|
console.log("Example app listening on port 3000!");
|
||||||
|
// if(!await db.has())
|
||||||
|
if (getCredentials() !== null) refreshToken(getCredentials().refresh_token);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("uncaughtException", function (err) {
|
||||||
|
console.log(err);
|
||||||
|
});
|
||||||
|
process.on("unhandledRejection", function (err) {
|
||||||
|
console.log(err);
|
||||||
|
});
|
644
hackclub-spotify-bot/src/public/hackclub.css
Normal file
644
hackclub-spotify-bot/src/public/hackclub.css
Normal file
|
@ -0,0 +1,644 @@
|
||||||
|
/* modified version of https://css.hackclub.com/theme.css */
|
||||||
|
:root {
|
||||||
|
/* why are the css vars swapped with incorrect names? well im to lazy to fix it. */
|
||||||
|
--darker: #121217;
|
||||||
|
--dark: #f9fafc;
|
||||||
|
--darkless: #e0e6ed;
|
||||||
|
--black: #fff;
|
||||||
|
--steel: #273444;
|
||||||
|
--slate: #3c4858;
|
||||||
|
--muted: #8492a6;
|
||||||
|
--smoke: #252429;
|
||||||
|
--snow: #17171d;
|
||||||
|
--white: #1f2d3d;
|
||||||
|
--red: #ec3750;
|
||||||
|
--orange: #ff8c37;
|
||||||
|
--yellow: #f1c40f;
|
||||||
|
--green: #33d6a6;
|
||||||
|
--cyan: #5bc0de;
|
||||||
|
--blue: #338eda;
|
||||||
|
--purple: #a633d6;
|
||||||
|
--text: var(--black);
|
||||||
|
--background: var(--white);
|
||||||
|
--elevated: var(--white);
|
||||||
|
--sheet: var(--snow);
|
||||||
|
--sunken: var(--smoke);
|
||||||
|
--border: var(--smoke);
|
||||||
|
--primary: #ec3750;
|
||||||
|
--secondary: #8492a6;
|
||||||
|
--accent: #5bc0de;
|
||||||
|
--twitter: #1da1f2;
|
||||||
|
--facebook: #3b5998;
|
||||||
|
--instagram: #e1306c;
|
||||||
|
--breakpoint-xs: 32em;
|
||||||
|
--breakpoint-s: 48em;
|
||||||
|
--breakpoint-m: 64em;
|
||||||
|
--breakpoint-l: 96em;
|
||||||
|
--breakpoint-xl: 128em;
|
||||||
|
--spacing-0: 0px;
|
||||||
|
--spacing-1: 4px;
|
||||||
|
--spacing-2: 8px;
|
||||||
|
--spacing-3: 16px;
|
||||||
|
--spacing-4: 32px;
|
||||||
|
--spacing-5: 64px;
|
||||||
|
--spacing-6: 128px;
|
||||||
|
--spacing-7: 256px;
|
||||||
|
--spacing-8: 512px;
|
||||||
|
--font-1: 12px;
|
||||||
|
--font-2: 16px;
|
||||||
|
--font-3: 20px;
|
||||||
|
--font-4: 24px;
|
||||||
|
--font-5: 32px;
|
||||||
|
--font-6: 48px;
|
||||||
|
--font-7: 64px;
|
||||||
|
--font-8: 96px;
|
||||||
|
--font-9: 128px;
|
||||||
|
--font-10: 160px;
|
||||||
|
--font-11: 192px;
|
||||||
|
--line-height-limit: 0.875;
|
||||||
|
--line-height-title: 1;
|
||||||
|
--line-height-heading: 1.125;
|
||||||
|
--line-height-subheading: 1.25;
|
||||||
|
--line-height-caption: 1.375;
|
||||||
|
--line-height-body: 1.5;
|
||||||
|
--font-weight-body: 400;
|
||||||
|
--font-weight-bold: 700;
|
||||||
|
--font-weight-heading: var(--font-weight-bold);
|
||||||
|
--letter-spacing-title: -0.009em;
|
||||||
|
--letter-spacing-headline: 0.009em;
|
||||||
|
--size-wide-plus: 2048px;
|
||||||
|
--size-wide: 1536px;
|
||||||
|
--size-layout-plus: 1200px;
|
||||||
|
--size-layout: 1024px;
|
||||||
|
--size-copy-ultra: 980px;
|
||||||
|
--size-copy-plus: 768px;
|
||||||
|
--size-copy: 680px;
|
||||||
|
--size-narrow-plus: 600px;
|
||||||
|
--size-narrow: 512px;
|
||||||
|
--radii-small: 4px;
|
||||||
|
--radii-default: 8px;
|
||||||
|
--radii-extra: 12px;
|
||||||
|
--radii-ultra: 16px;
|
||||||
|
--radii-circle: 99999px;
|
||||||
|
--shadow-text: 0 1px 2px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.125);
|
||||||
|
--shadow-small: 0 1px 2px rgba(0, 0, 0, 0.0625),
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.0625);
|
||||||
|
--shadow-card: 0 4px 8px rgba(0, 0, 0, 0.125);
|
||||||
|
--shadow-elevated: 0 1px 2px rgba(0, 0, 0, 0.0625),
|
||||||
|
0 8px 12px rgba(0, 0, 0, 0.125);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
"Phantom Sans",
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
Roboto,
|
||||||
|
sans-serif;
|
||||||
|
line-height: var(--line-height-body);
|
||||||
|
font-weight: var(--font-weight-body);
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
font-smooth: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
color: var(--text);
|
||||||
|
background-color: var(--background);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monospace {
|
||||||
|
font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ultratitle {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-limit);
|
||||||
|
letter-spacing: var(--letter-spacing-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-title);
|
||||||
|
letter-spacing: var(--letter-spacing-title);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin-top: var(--spacing-3);
|
||||||
|
font-weight: var(--font-weight-body);
|
||||||
|
line-height: var(--line-height-subheading);
|
||||||
|
letter-spacing: var(--letter-spacing-headline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
margin-top: var(--spacing-3);
|
||||||
|
margin-bottom: var(--spacing-3);
|
||||||
|
font-size: var(--font-4);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
letter-spacing: var(--letter-spacing-headline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subheadline {
|
||||||
|
margin-top: var(--spacing-0);
|
||||||
|
margin-bottom: var(--spacing-3);
|
||||||
|
font-size: var(--font-2);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
letter-spacing: var(--letter-spacing-headline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: var(--font-weight-heading);
|
||||||
|
letter-spacing: var(--letter-spacing-headline);
|
||||||
|
line-height: var(--line-height-subheading);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: var(--spacing-0);
|
||||||
|
margin-bottom: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
font-weight: var(--font-weight-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: var(--font-weight-body);
|
||||||
|
letter-spacing: var(--letter-spacing-headline);
|
||||||
|
line-height: var(--line-height-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
border-radius: var(--radii-circle);
|
||||||
|
padding-left: var(--spacing-3);
|
||||||
|
padding-right: var(--spacing-3);
|
||||||
|
padding-top: var(--spacing-1);
|
||||||
|
padding-bottom: var(--spacing-1);
|
||||||
|
font-size: var(--font-2);
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--background);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline-badge {
|
||||||
|
border-radius: var(--radii-circle);
|
||||||
|
padding-left: var(--spacing-3);
|
||||||
|
padding-right: var(--spacing-3);
|
||||||
|
padding-top: var(--spacing-1);
|
||||||
|
padding-bottom: var(--spacing-1);
|
||||||
|
font-size: var(--font-2);
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid currentcolor;
|
||||||
|
font-weight: var(--font-weight-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
border-radius: var(--radii-circle);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
letter-spacing: var(--letter-spacing-headline);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition:
|
||||||
|
transform 0.125s ease-in-out,
|
||||||
|
box-shadow 0.125s ease-in-out;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
text-align: center;
|
||||||
|
line-height: inherit;
|
||||||
|
-webkit-text-decoration: none;
|
||||||
|
text-decoration: none;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: var(--theme-ui-colors-white, #ffffff);
|
||||||
|
background-color: var(--theme-ui-colors-primary, #ec3750);
|
||||||
|
border: 0;
|
||||||
|
font-size: var(--font-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
button:hover {
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
transform: scale(1.0625);
|
||||||
|
}
|
||||||
|
.button {
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
border-radius: var(--radii-circle);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
letter-spacing: var(--letter-spacing-headline);
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition:
|
||||||
|
transform 0.125s ease-in-out,
|
||||||
|
box-shadow 0.125s ease-in-out;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
text-align: center;
|
||||||
|
line-height: inherit;
|
||||||
|
-webkit-text-decoration: none;
|
||||||
|
text-decoration: none;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: var(--theme-ui-colors-white, #ffffff);
|
||||||
|
background-color: var(--theme-ui-colors-primary, #ec3750);
|
||||||
|
border: 0;
|
||||||
|
font-size: var(--font-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:focus,
|
||||||
|
.button:hover {
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
transform: scale(1.0625);
|
||||||
|
}
|
||||||
|
button.lg {
|
||||||
|
font-size: var(--font-3) !important;
|
||||||
|
line-height: var(--line-height-title);
|
||||||
|
padding-left: var(--spacing-4);
|
||||||
|
padding-right: var(--spacing-4);
|
||||||
|
padding-top: var(--spacing-3);
|
||||||
|
padding-bottom: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.outline {
|
||||||
|
background: none;
|
||||||
|
color: var(--primary);
|
||||||
|
border: 2px solid currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.cta {
|
||||||
|
font-size: var(--font-2);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
ellipse farthest-corner at top left,
|
||||||
|
var(--orange),
|
||||||
|
var(--red)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--elevated);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radii-extra);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.sunken {
|
||||||
|
background: var(--sunken);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.interactive {
|
||||||
|
text-decoration: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition:
|
||||||
|
transform 0.125s ease-in-out,
|
||||||
|
box-shadow 0.125s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.interactive:hover,
|
||||||
|
.card.interactive:focus {
|
||||||
|
transform: scale(1.0625);
|
||||||
|
box-shadow: var(--shadow-elevated);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
background: var(--elevated);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
border-radius: var(--radii-small);
|
||||||
|
border: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: var(--spacing-2);
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::-webkit-input-placeholder,
|
||||||
|
input::-moz-placeholder,
|
||||||
|
input:-ms-input-placeholder,
|
||||||
|
textarea::-webkit-input-placeholder,
|
||||||
|
textarea::-moz-placeholder,
|
||||||
|
textarea:-ms-input-placeholder,
|
||||||
|
select::-webkit-input-placeholder,
|
||||||
|
select::-moz-placeholder,
|
||||||
|
select:-ms-input-placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"]::-webkit-search-decoration,
|
||||||
|
textarea[type="search"]::-webkit-search-decoration,
|
||||||
|
select[type="search"]::-webkit-search-decoration {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
-webkit-appearance: checkbox;
|
||||||
|
-moz-appearance: checkbox;
|
||||||
|
appearance: checkbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
color: var(--text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
line-height: var(--line-height-caption);
|
||||||
|
font-size: var(--font-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
label.horizontal {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hidden {
|
||||||
|
position: absolute;
|
||||||
|
height: 1px;
|
||||||
|
width: 1px;
|
||||||
|
clip: rect(1px, 1px, 1px, 1px);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
margin: auto;
|
||||||
|
padding-left: var(--spacing-3);
|
||||||
|
padding-right: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: var(--font-5);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--font-4);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--font-3);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: var(--font-2);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: var(--font-1);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: var(--font-weight-body);
|
||||||
|
line-height: var(--line-height-body);
|
||||||
|
margin-top: var(--spacing-3);
|
||||||
|
margin-bottom: var(--spacing-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-position: under;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus,
|
||||||
|
a:hover {
|
||||||
|
text-decoration-style: wavy;
|
||||||
|
text-decoration-skip-ink: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: var(--font-1);
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--sunken);
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: var(--radii-default);
|
||||||
|
white-space: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre > code {
|
||||||
|
color: inherit;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: inherit;
|
||||||
|
color: var(--purple);
|
||||||
|
background: var(--sunken);
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: var(--radii-small);
|
||||||
|
margin-left: var(--spacing-1);
|
||||||
|
margin-right: var(--spacing-1);
|
||||||
|
padding-left: var(--spacing-1);
|
||||||
|
padding-right: var(--spacing-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
p > code,
|
||||||
|
li > code {
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p > a > code,
|
||||||
|
li > a > code {
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-top: var(--spacing-2);
|
||||||
|
margin-bottom: var(--spacing-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: var(--spacing-4);
|
||||||
|
margin-bottom: var(--spacing-4);
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table > th,
|
||||||
|
table > td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px;
|
||||||
|
padding-left: 0px;
|
||||||
|
border-color: var(--border);
|
||||||
|
border-bottom-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
vertical-align: bottom;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 32em) {
|
||||||
|
.ultratitle {
|
||||||
|
font-size: var(--font-5);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: var(--font-4);
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: var(--font-2);
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
font-size: var(--font-3);
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
font-size: var(--font-2);
|
||||||
|
margin-top: var(--spacing-2);
|
||||||
|
margin-bottom: var(--spacing-2);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: var(--spacing-3);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: var(--size-layout);
|
||||||
|
}
|
||||||
|
.container.copy {
|
||||||
|
max-width: var(--size-copy);
|
||||||
|
}
|
||||||
|
.container.narrow {
|
||||||
|
max-width: var(--size-narrow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 48em) {
|
||||||
|
.ultratitle {
|
||||||
|
font-size: var(--font-6);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: var(--font-5);
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
font-size: var(--font-3);
|
||||||
|
}
|
||||||
|
.eyebrow {
|
||||||
|
font-size: var(--font-4);
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
font-size: var(--font-3);
|
||||||
|
margin-top: var(--spacing-3);
|
||||||
|
margin-bottom: var(--spacing-3);
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
padding: var(--spacing-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 64em) {
|
||||||
|
.ultratitle {
|
||||||
|
font-size: var(--font-7);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: var(--font-6);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: var(--size-layout-plus);
|
||||||
|
}
|
||||||
|
.container.wide {
|
||||||
|
max-width: var(--size-wide);
|
||||||
|
}
|
||||||
|
.container.copy {
|
||||||
|
max-width: var(--size-copy-plus);
|
||||||
|
}
|
||||||
|
.container.narrow {
|
||||||
|
max-width: var(--size-narrow-plus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* custom additions */
|
||||||
|
.error {
|
||||||
|
color: var(--red);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.success {
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
199
hackclub-spotify-bot/src/spotify.js
Normal file
199
hackclub-spotify-bot/src/spotify.js
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
let token = null;
|
||||||
|
let authStuff = null;
|
||||||
|
const client_id = process.env.SPOTIFY_CLIENT_ID;
|
||||||
|
const client_secret = process.env.SPOTIFY_CLIENT_SECRET;
|
||||||
|
const redirect_uri = process.env.SPOTIFY_REDIRECT_URI;
|
||||||
|
async function fetchWebApi(endpoint, method, body) {
|
||||||
|
const res = await fetch(`https://api.spotify.com/${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
// console.debug(text)
|
||||||
|
// abs nothing is wrong
|
||||||
|
return JSON.parse(text.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoginUrl() {
|
||||||
|
const state = generateRandomString(16);
|
||||||
|
const scope = [
|
||||||
|
// "ugc-image-upload",
|
||||||
|
// "user-read-playback-state",
|
||||||
|
// "user-modify-playback-state",
|
||||||
|
// "user-read-currently-playing",
|
||||||
|
// "app-remote-control",
|
||||||
|
// "streaming",
|
||||||
|
"playlist-read-private",
|
||||||
|
"playlist-read-collaborative",
|
||||||
|
"playlist-modify-private",
|
||||||
|
"playlist-modify-public",
|
||||||
|
// "user-follow-modify",
|
||||||
|
// "user-follow-read",
|
||||||
|
// "user-read-playback-position",
|
||||||
|
// "user-top-read",
|
||||||
|
// "user-read-recently-played",
|
||||||
|
"user-library-modify",
|
||||||
|
// "user-library-read",
|
||||||
|
// "user-read-email",
|
||||||
|
"user-read-private",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
"https://accounts.spotify.com/authorize?" +
|
||||||
|
`response_type=code&grant_type=client_credentials&client_id=${client_id}&scope=${scope}&redirect_uri=${redirect_uri}&state=${state}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRandomString(length) {
|
||||||
|
let result = "";
|
||||||
|
const characters =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
const charactersLength = characters.length;
|
||||||
|
let counter = 0;
|
||||||
|
while (counter < length) {
|
||||||
|
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
async function refreshToken(refresh_token) {
|
||||||
|
try {
|
||||||
|
// var refresh_token = req.query.refresh_token;
|
||||||
|
const authOptions = {
|
||||||
|
url: "https://accounts.spotify.com/api/token",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization:
|
||||||
|
"Basic " +
|
||||||
|
new Buffer.from(client_id + ":" + client_secret).toString("base64"),
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refresh_token,
|
||||||
|
},
|
||||||
|
json: true,
|
||||||
|
};
|
||||||
|
const formdm = new URLSearchParams();
|
||||||
|
|
||||||
|
formdm.append("grant_type", "refresh_token");
|
||||||
|
formdm.append("refresh_token", refresh_token);
|
||||||
|
|
||||||
|
fetch(authOptions.url, {
|
||||||
|
body: formdm,
|
||||||
|
headers: authOptions.headers,
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
.then(async (r) => {
|
||||||
|
const text = await r.text();
|
||||||
|
// console.log(text);
|
||||||
|
return JSON.parse(text);
|
||||||
|
})
|
||||||
|
.then((auth) => {
|
||||||
|
if (!auth.refresh_token) auth.refresh_token = refresh_token;
|
||||||
|
// console.log(auth);
|
||||||
|
authStuff = auth;
|
||||||
|
token = auth.access_token;
|
||||||
|
saveCredentials(auth);
|
||||||
|
if (auth.expires_in) {
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshToken(auth.refresh_token);
|
||||||
|
}, auth.expires_in * 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Welp it broke`);
|
||||||
|
// try again asap because we NEED THAT TOKEN
|
||||||
|
refreshToken(refresh_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveCredentials(creds) {
|
||||||
|
require("fs").writeFileSync(
|
||||||
|
"data/credentials.json",
|
||||||
|
JSON.stringify(creds, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function getCredentials() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(
|
||||||
|
require("fs").readFileSync("data/credentials.json", "utf8"),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function spotifyRoutes(app) {
|
||||||
|
app.get("/spotify/callback", async (req, res) => {
|
||||||
|
const code = req.query.code || null;
|
||||||
|
const state = req.query.state || null;
|
||||||
|
|
||||||
|
if (state === null) {
|
||||||
|
res.redirect(
|
||||||
|
"/#" +
|
||||||
|
querystring.stringify({
|
||||||
|
error: "state_mismatch",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const authOptions = {
|
||||||
|
url: "https://accounts.spotify.com/api/token",
|
||||||
|
form: {
|
||||||
|
code: code,
|
||||||
|
redirect_uri: redirect_uri,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
Authorization:
|
||||||
|
"Basic " +
|
||||||
|
new Buffer.from(client_id + ":" + client_secret).toString("base64"),
|
||||||
|
},
|
||||||
|
json: true,
|
||||||
|
};
|
||||||
|
const formdm = new URLSearchParams();
|
||||||
|
// Object.entries(authOptions.form).forEach(([key, value]) => {
|
||||||
|
// formdm.append(key, value);
|
||||||
|
// })
|
||||||
|
formdm.append("code", code);
|
||||||
|
formdm.append("redirect_uri", redirect_uri);
|
||||||
|
formdm.append("grant_type", "authorization_code");
|
||||||
|
|
||||||
|
fetch(authOptions.url, {
|
||||||
|
body: formdm,
|
||||||
|
headers: authOptions.headers,
|
||||||
|
method: "POST",
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((auth) => {
|
||||||
|
// console.log(auth);
|
||||||
|
authStuff = auth;
|
||||||
|
saveCredentials(auth);
|
||||||
|
token = auth.access_token;
|
||||||
|
if (auth.expires_in) {
|
||||||
|
setTimeout(() => {
|
||||||
|
refreshToken(auth.refresh_token);
|
||||||
|
}, auth.expires_in * 1000);
|
||||||
|
}
|
||||||
|
res.status(200).end("Successfully logged in!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function addSongToPlaylist(url) {
|
||||||
|
fetchWebApi("v1/playlists/3gRv97fvllFFLVdCH6XzsE/tracks", "POST", {
|
||||||
|
uris: [url],
|
||||||
|
position: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = {
|
||||||
|
getLoginUrl,
|
||||||
|
refreshToken,
|
||||||
|
saveCredentials,
|
||||||
|
getCredentials,
|
||||||
|
spotifyRoutes,
|
||||||
|
addSongToPlaylist,
|
||||||
|
|
||||||
|
// getToken
|
||||||
|
};
|
71
hackclub-spotify-bot/src/views/home.ejs
Normal file
71
hackclub-spotify-bot/src/views/home.ejs
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><%= title %></title>
|
||||||
|
|
||||||
|
<meta property="og:title" content="<%=title%>" />
|
||||||
|
<meta name="twitter:title" content="<%=title%>" />
|
||||||
|
<meta name="description" content="<%=description %>" />
|
||||||
|
<meta property="og:description" content="<%=description %>" />
|
||||||
|
<meta name="twitter:description" content="<%=description %>" />
|
||||||
|
<link rel="stylesheet" href="./hackclub.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<center>
|
||||||
|
<br>
|
||||||
|
<header>
|
||||||
|
<h1 class="ultratitle">Hackclub spotify </h1>
|
||||||
|
<!-- <p class="headline">Looking for a basic website template that includes Hack Club's theme CSS? Look no further!</p> -->
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
<div class="card container" style="max-width: 550px;">
|
||||||
|
<h2 class="headline">Submit Song</h2>
|
||||||
|
<!-- <p> -->
|
||||||
|
<form action="/spotify/submitsong?token=<%=onetimetoken%>" method="POST" >
|
||||||
|
|
||||||
|
<div class="interactive">
|
||||||
|
<label>Song URL</label>
|
||||||
|
<input type="url" name="songurl" <%=s ? "disabled='true'" : '' %>placeholder="https://open.spotify.com/track/6aWOvqmjb3343D5sq7zMgl?si=b72b81a97e564dc3" />
|
||||||
|
<br>
|
||||||
|
</div>
|
||||||
|
<!-- </p> -->
|
||||||
|
<% if (s) { %>
|
||||||
|
<!-- <meta property="hhtp-equiv" content="refresh"> -->
|
||||||
|
<meta http-equiv="refresh" content="5; url='/home'" />
|
||||||
|
<div class="success">
|
||||||
|
<h2>Success!</h2>
|
||||||
|
<p class="success">Your song has been added to the playlist!</p>
|
||||||
|
<p>refreshing page in 5s</p>
|
||||||
|
<br>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (error) { %>
|
||||||
|
<div class="error">
|
||||||
|
<%= error %>
|
||||||
|
<br>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<button type="submit" <%=s ? "disabled='true'" : '' %> >Submit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<footer style="display: inline-flex;padding-left: 2px;padding-right: 2px;">
|
||||||
|
<a href="/logout" class="button" style="padding-left: 2px;padding-right: 2px;">Logout</a>
|
||||||
|
<div>
|
||||||
|
<a href="/download/db" download class="button" style="margin-left: 8px;margin-right: 2px;">Download all song entries</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
<p style="max-width: 250px; inline-size: max-content; word-break: break-all;color: gray;"> The "Download all song entries" button includes all songs submited thru the form (even if removed from the playlist)</p>
|
||||||
|
<a href="https://open.spotify.com/playlist/3gRv97fvllFFLVdCH6XzsE?si=eUQm8275QdyMquBbNdPVHA">Check out the playlist</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
38
hackclub-spotify-bot/src/views/index.ejs
Normal file
38
hackclub-spotify-bot/src/views/index.ejs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><%= title %></title>
|
||||||
|
|
||||||
|
<meta property="og:title" content="<%=title%>" />
|
||||||
|
<meta name="twitter:title" content="<%=title%>" />
|
||||||
|
<meta name="description" content="<%=description %>" />
|
||||||
|
<meta property="og:description" content="<%=description %>" />
|
||||||
|
<meta name="twitter:description" content="<%=description %>" />
|
||||||
|
<link rel="stylesheet" href="./hackclub.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<center>
|
||||||
|
<br>
|
||||||
|
<header>
|
||||||
|
<h1 class="ultratitle">Hackclub spotify</h1>
|
||||||
|
<!-- <p class="headline">Looking for a basic website template that includes Hack Club's theme CSS? Look no further!</p> -->
|
||||||
|
</header>
|
||||||
|
<div>
|
||||||
|
<div class="card container">
|
||||||
|
<!-- <h2 class="headline"></h2> -->
|
||||||
|
<p>
|
||||||
|
This is a bot which will contribute to the spotify playlist which is for the hackclub community!
|
||||||
|
<br>
|
||||||
|
You may only contribute via this site; direct access to the playlist will not be allowed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<a href="/login">Login Page</a>
|
||||||
|
<a href="https://open.spotify.com/playlist/3gRv97fvllFFLVdCH6XzsE?si=eUQm8275QdyMquBbNdPVHA">Check out the playlist</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
</html>
|
1523
hackclub-spotify-bot/yarn.lock
Normal file
1523
hackclub-spotify-bot/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
1
sent/.gitignore
vendored
Normal file
1
sent/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
*
|
|
@ -1,10 +0,0 @@
|
||||||
Date: Fri, 13 Sep 2024 01:41:52 +0000
|
|
||||||
From: neon <neon@hackclub.app>
|
|
||||||
To: neon@saahild.com
|
|
||||||
Subject: Hi
|
|
||||||
Message-ID: <ZuOYYH41PzEkqYrv@hackclub.app>
|
|
||||||
MIME-Version: 1.0
|
|
||||||
Content-Type: text/plain; charset=us-ascii
|
|
||||||
Content-Disposition: inline
|
|
||||||
|
|
||||||
Hello World!
|
|
Loading…
Reference in a new issue