diff --git a/hackclub-spotify-bot/src/.env.example b/hackclub-spotify-bot/.env.example similarity index 69% rename from hackclub-spotify-bot/src/.env.example rename to hackclub-spotify-bot/.env.example index 0b5bff2..7395f7d 100644 --- a/hackclub-spotify-bot/src/.env.example +++ b/hackclub-spotify-bot/.env.example @@ -3,4 +3,5 @@ SLACK_CLIENT_SECRET= SLACK_TOKEN= SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= -SPOTIFY_REDIRECT_URI= \ No newline at end of file +SPOTIFY_REDIRECT_URI= +SLACK_REDIRECT_URI= \ No newline at end of file diff --git a/hackclub-spotify-bot/.gitignore b/hackclub-spotify-bot/.gitignore index 1be5c96..819e52e 100644 --- a/hackclub-spotify-bot/.gitignore +++ b/hackclub-spotify-bot/.gitignore @@ -1,3 +1,4 @@ node_modules .env -db.json \ No newline at end of file +db.json +data/* diff --git a/hackclub-spotify-bot/TODO.md b/hackclub-spotify-bot/TODO.md new file mode 100644 index 0000000..7bf6c50 --- /dev/null +++ b/hackclub-spotify-bot/TODO.md @@ -0,0 +1,15 @@ +- [ ] 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) +- - [ ] fix the [channel](https://app.slack.com/client/T0266FRGM/C07RE4N7S4B) +- - - [x] Add ping for new-song event & send message ovs +- [ ] 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 +- [ ] transparency of added songs +- - [ ] export db -> into csv with properties (slack_id, url, added_at) diff --git a/hackclub-spotify-bot/package.json b/hackclub-spotify-bot/package.json index a62b1c4..88833ad 100644 --- a/hackclub-spotify-bot/package.json +++ b/hackclub-spotify-bot/package.json @@ -8,10 +8,16 @@ }, "dependencies": { "@slack/oauth": "^3.0.1", - "@slack/web-api": "^7.6.0", + "better-sqlite3": "^11.3.0", "dotenv": "^16.4.5", "ejs": "^3.1.10", "express": "^4.21.1", - "express-session": "^1.18.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" } } diff --git a/hackclub-spotify-bot/src/TODO.md b/hackclub-spotify-bot/src/TODO.md deleted file mode 100644 index 0a84465..0000000 --- a/hackclub-spotify-bot/src/TODO.md +++ /dev/null @@ -1,15 +0,0 @@ -- [ ] Slack stuff -- - [ ] web api [docs](https://tools.slack.dev/node-slack-sdk/web-api/) -- - [ ] oauth [docs](https://tools.slack.dev/node-slack-sdk/oauth) -- - [ ] fix the [channel](https://app.slack.com/client/T0266FRGM/C07RE4N7S4B) -- - - [ ] Add ping for new-song event & send message ovs -- [ ] Spotify -- - [ ] web api [docs](https://developer.spotify.com/documentation/web-api/) -- - [ ] oauth [docs](https://developer.spotify.com/documentation/general/guides/authorization-guide/) -- - [ ] refresh token [docs](https://developer.spotify.com/documentation/general/guides/authorization-guide/#refresh-an-access-token) -- - [ ] playlist tools (creation,modifcation,deletion) -- [ ] keydb (quick db or smthing) -- [ ] express -- [ ] transparency of added songs -- - [ ] export db -> into csv with properties (slack_id, slack_name, spotify_id, spotify_url) - diff --git a/hackclub-spotify-bot/src/index.js b/hackclub-spotify-bot/src/index.js index 81b86bd..98dca8d 100644 --- a/hackclub-spotify-bot/src/index.js +++ b/hackclub-spotify-bot/src/index.js @@ -1,19 +1,60 @@ const path = require("path"); -require("dotenv").config({ path: path.join(__dirname, ".env") }); -console.debug(process.env); +require("dotenv").config(); const express = require("express"); const session = require("express-session"); -const { WebClient } = require("@slack/web-api"); -const { InstallProvider } = require("@slack/oauth"); -const { getLoginUrl, refreshToken } = require("./spotify"); +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", +}); +let cacheDb = {}; const app = express(); +const userScopes = ["identity.avatar", "identity.basic", "identity.team"]; // Initialize -const web = new WebClient(process.env.SLACK_TOKEN); const oauth = new InstallProvider({ clientId: process.env.SLACK_CLIENT_ID, clientSecret: process.env.SLACK_CLIENT_SECRET, - stateSecret: Math.random().toString(36).substring(2), + 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"))); @@ -22,24 +63,170 @@ app.set("views", "src/views"); app.use(express.urlencoded({ extended: true })); app.use( session({ - secret: Math.random().toString(36).substring(2), - resave: false, + secret: process.env.STATE_SECRET, + resave: true, + store: new FileStore({ + path: path.join(__dirname, "../data/sessions"), + }), saveUninitialized: true, - cookie: { secure: true }, + cookie: { secure: "auto", maxAge: 1000 * 60 * 60 * 24 * 365 }, }), ); +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!", }); }); -app.get("/login", async (req, res) => { - if (req.session.token) { +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.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: ``, + }), + 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()); }); -app.listen(process.env.PORT || 3000, () => { +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); }); diff --git a/hackclub-spotify-bot/src/public/hackclub.css b/hackclub-spotify-bot/src/public/hackclub.css index 1816be0..feb83cb 100644 --- a/hackclub-spotify-bot/src/public/hackclub.css +++ b/hackclub-spotify-bot/src/public/hackclub.css @@ -1,589 +1,635 @@ -/* 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.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); - } - } - \ No newline at end of file +/* 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); + } +} diff --git a/hackclub-spotify-bot/src/spotify.js b/hackclub-spotify-bot/src/spotify.js index 4caa082..b444435 100644 --- a/hackclub-spotify-bot/src/spotify.js +++ b/hackclub-spotify-bot/src/spotify.js @@ -1,113 +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, - }; - console.log(authOptions); - 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; - 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); - } - } - \ No newline at end of file +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 +}; diff --git a/hackclub-spotify-bot/src/views/home.ejs b/hackclub-spotify-bot/src/views/home.ejs new file mode 100644 index 0000000..e3d4601 --- /dev/null +++ b/hackclub-spotify-bot/src/views/home.ejs @@ -0,0 +1,62 @@ + + +
+ + +