feat: spotify + slack (#3)

Co-authored-by: NeonGamerBot-QK <saahilattud@gmail.com>
Co-authored-by: zeon-neon[bot] <136533918+zeon-neon[bot]@users.noreply.github.com>
This commit is contained in:
Saahil dutta 2024-10-13 00:19:50 -04:00 committed by GitHub
parent 1282743295
commit f1eaf98e84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2795 additions and 2 deletions

1
.gitignore vendored
View file

@ -18,5 +18,6 @@ sent/* # all sent mail is there
.cache/*
.env
.env.*
!.env.example
.uptime-url
main.cron

View file

@ -9,5 +9,7 @@ http://neon.hackclub.app {
}
}
http://spotify.hackclub.app {
bind unix/.spotify.sock|777
reverse_proxy localhost:378625
}

View 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
View file

@ -0,0 +1,4 @@
node_modules
.env
db.json
data/*

View file

@ -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
- [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)

View 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"
}
}

View 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);
});

View 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;
}

View 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
};

View 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" disabled="<%=s%>">
<div class="interactive">
<label>Song URL</label>
<input type="url" name="songurl" disabled="<%=s%>" 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" disabled="<%=s%>">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>

View 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>
Note: WIP
</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>

File diff suppressed because it is too large Load diff