├── apiLimiter.json
├── .prettierignore
├── robots.txt
├── .prettierrc
├── .husky
└── pre-commit
├── migrations
├── sqls
│ ├── 20240918190940-add-duration-down.sql
│ ├── 20250304181536-add-twitch-id-on-user-down.sql
│ ├── 20250304181247-update-username-on-user-down.sql
│ ├── 20240918190940-add-duration-up.sql
│ ├── 20250304181536-add-twitch-id-on-user-up.sql
│ ├── 20250304181247-update-username-on-user-up.sql
│ ├── 20250306182624-api-key-system-down.sql
│ └── 20250306182624-api-key-system-up.sql
├── 20250306182624-api-key-system.js
├── 20250304181536-add-twitch-id-on-user.js
├── 20250304181247-update-username-on-user.js
└── 20240918190940-add-duration.js
├── src
└── frontend
│ ├── index.ts
│ ├── main.ts
│ └── iframe.ts
├── jest.config.js
├── public
└── assets
│ ├── icons
│ ├── delete.png
│ ├── edit.png
│ ├── play.png
│ ├── shuffle.png
│ ├── wiggle_outine.gif
│ └── arrow.svg
│ ├── javascript
│ ├── index.js
│ ├── index.js.map
│ ├── main.js
│ ├── main.js.map
│ ├── iframe.js.map
│ └── iframe.js
│ └── css
│ ├── reset.css
│ └── myStyle.css
├── .gitignore
├── views
├── error.pug
├── signin.pug
├── edit_song.pug
├── add_contributors.pug
├── twitch_signup.pug
├── create_playlist.pug
├── signup.pug
├── edit_playlist.pug
├── public_playlists.pug
├── layout.pug
├── contributors.pug
├── playlists.pug
└── playlist.pug
├── .env.example
├── lib
├── errors.js
├── instrument.js
├── pg-connect.js
├── playlist.test.js
├── schema.sql
├── msg.json
├── playlist.js
├── pg-persistence.test.js
├── seed.sql
└── pg-persistence.js
├── routes
├── catch-error.js
├── authRouter.js
├── middleware.test.js
├── middleware.ts
├── contributorsRouter.js
├── apiRouter.js
├── songsRouter.js
└── playlistsRouter.js
├── database.json
├── tsconfig.frontend.json
├── myserver.service
├── README.md
├── eslint.config.mjs
├── Utilities
├── addSongDuration.js
└── addTwitchId.js
├── package.json
├── app.js
├── .github
└── workflows
│ └── node.js.yml
└── transpiledjs
└── middleware.js
/apiLimiter.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /migrations
--------------------------------------------------------------------------------
/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-sql"]
3 | }
4 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | npm test
2 | npx prettier . --check
3 | npx eslint .
4 |
--------------------------------------------------------------------------------
/migrations/sqls/20240918190940-add-duration-down.sql:
--------------------------------------------------------------------------------
1 | /* Replace with your SQL commands */
2 |
--------------------------------------------------------------------------------
/migrations/sqls/20250304181536-add-twitch-id-on-user-down.sql:
--------------------------------------------------------------------------------
1 | /* Replace with your SQL commands */
--------------------------------------------------------------------------------
/src/frontend/index.ts:
--------------------------------------------------------------------------------
1 | import { initalizeIframe } from "./iframe.js";
2 | initalizeIframe();
3 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | require("dotenv").config({
2 | path: ".env",
3 | });
4 |
5 | module.exports = {};
6 |
--------------------------------------------------------------------------------
/migrations/sqls/20250304181247-update-username-on-user-down.sql:
--------------------------------------------------------------------------------
1 | /* Replace with your SQL commands */
--------------------------------------------------------------------------------
/migrations/sqls/20240918190940-add-duration-up.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE songs
2 | ADD COLUMN duration_sec integer;
3 |
--------------------------------------------------------------------------------
/public/assets/icons/delete.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nhancodes/nhanify/HEAD/public/assets/icons/delete.png
--------------------------------------------------------------------------------
/public/assets/icons/edit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nhancodes/nhanify/HEAD/public/assets/icons/edit.png
--------------------------------------------------------------------------------
/public/assets/icons/play.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nhancodes/nhanify/HEAD/public/assets/icons/play.png
--------------------------------------------------------------------------------
/public/assets/icons/shuffle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nhancodes/nhanify/HEAD/public/assets/icons/shuffle.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode/
2 | session-store.db
3 | *.swp
4 | .psql_history
5 | .env
6 | node_modules/
7 | backup.sql
8 | .idea/
--------------------------------------------------------------------------------
/migrations/sqls/20250304181536-add-twitch-id-on-user-up.sql:
--------------------------------------------------------------------------------
1 | ALTER TABLE users
2 | ADD COLUMN twitch_id VARCHAR(50) UNIQUE;
--------------------------------------------------------------------------------
/migrations/sqls/20250304181247-update-username-on-user-up.sql:
--------------------------------------------------------------------------------
1 | UPDATE users
2 | SET username = 'holyluck_'
3 | WHERE id = 7;
--------------------------------------------------------------------------------
/public/assets/icons/wiggle_outine.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nhancodes/nhanify/HEAD/public/assets/icons/wiggle_outine.gif
--------------------------------------------------------------------------------
/migrations/sqls/20250306182624-api-key-system-down.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS api_requests;
2 |
3 | ALTER TABLE users DROP COLUMN api_key;
4 |
--------------------------------------------------------------------------------
/public/assets/javascript/index.js:
--------------------------------------------------------------------------------
1 | import { initalizeIframe } from "./iframe.js";
2 | initalizeIframe();
3 | //# sourceMappingURL=index.js.map
4 |
--------------------------------------------------------------------------------
/views/error.pug:
--------------------------------------------------------------------------------
1 | extend layout
2 | block main
3 | p(class="errorTxt") #{statusCode}
4 | p(class="errorTxt") #{msg}
5 | p(class="errorTxt") #{msg2}
6 |
--------------------------------------------------------------------------------
/views/signin.pug:
--------------------------------------------------------------------------------
1 | extends signup
2 | block header
3 | h1(class="pageTitle") Sign In
4 | .jam
5 | block content
6 | div
7 | a(class="signin" href="/twitchAuth") Sign In With Twitch
8 |
9 |
--------------------------------------------------------------------------------
/public/assets/javascript/index.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/frontend/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,eAAe,EAAC,MAAM,aAAa,CAAC;AAC5C,eAAe,EAAE,CAAC"}
--------------------------------------------------------------------------------
/src/frontend/main.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | export function confirmSubmit(event: Event, message: string) {
3 | event.preventDefault();
4 | if (confirm(message) && event.target) {
5 | (event.target as HTMLFormElement).submit();
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | #Fill the the configuration values.
2 | PG_USER=
3 | #Do not change the PG_DATABASE configuration.
4 | PG_DATABASE=nhanify
5 | PG_PASSWORD=
6 | HOST=
7 | PORT=
8 | SESSION_SECRET=
9 | #Twitch oauth keys
10 | CLIENT_ID=
11 | CLIENT_SECRET=
--------------------------------------------------------------------------------
/public/assets/javascript/main.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | export function confirmSubmit(event, message) {
3 | event.preventDefault();
4 | if (confirm(message) && event.target) {
5 | event.target.submit();
6 | }
7 | }
8 | //# sourceMappingURL=main.js.map
9 |
--------------------------------------------------------------------------------
/public/assets/icons/arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/errors.js:
--------------------------------------------------------------------------------
1 | class NotFoundError extends Error {}
2 | class ForbiddenError extends Error {}
3 | class TooManyError extends Error {}
4 | class BadRequestError extends Error {}
5 | module.exports = {
6 | TooManyError,
7 | NotFoundError,
8 | ForbiddenError,
9 | BadRequestError,
10 | };
11 |
--------------------------------------------------------------------------------
/public/assets/javascript/main.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"main.js","sourceRoot":"","sources":["../../../src/frontend/main.ts"],"names":[],"mappings":"AAAA,oBAAoB;AACnB,MAAM,UAAU,aAAa,CAAC,KAAY,EAAE,OAAe;IAC1D,KAAK,CAAC,cAAc,EAAE,CAAC;IACvB,IAAI,OAAO,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,MAAM,EAAE,CAAC;QACpC,KAAK,CAAC,MAA0B,CAAC,MAAM,EAAE,CAAC;IAC7C,CAAC;AACH,CAAC"}
--------------------------------------------------------------------------------
/routes/catch-error.js:
--------------------------------------------------------------------------------
1 | // wrapper for async middleware. Elminates need to catch errors.
2 | const catchError = (handler) => {
3 | return (req, res, next) => {
4 | Promise.resolve(handler(req, res, next)).catch((err) => {
5 | console.log("ERR", err);
6 | next(err);
7 | });
8 | };
9 | };
10 |
11 | module.exports = catchError;
12 |
--------------------------------------------------------------------------------
/database.json:
--------------------------------------------------------------------------------
1 | {
2 | "pg": {
3 | "driver": "pg",
4 | "user": {
5 | "ENV": "PG_USER"
6 | },
7 | "password": {
8 | "ENV": "PG_PASSWORD"
9 | },
10 | "host": "localhost",
11 | "database": {
12 | "ENV": "PG_DATABASE"
13 | },
14 | "port": "5432",
15 | "ssl": false,
16 | "schema": "my_schema"
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/lib/instrument.js:
--------------------------------------------------------------------------------
1 | const { SENTRY_DNS } = process.env;
2 | // Import with `import * as Sentry from "@sentry/node"` if you are using ESM
3 | const Sentry = require("@sentry/node");
4 |
5 | Sentry.init({
6 | dsn: SENTRY_DNS,
7 |
8 | // Setting this option to true will send default PII data to Sentry.
9 | // For example, automatic IP address collection on events
10 | sendDefaultPii: true,
11 | });
12 |
--------------------------------------------------------------------------------
/migrations/sqls/20250306182624-api-key-system-up.sql:
--------------------------------------------------------------------------------
1 |
2 | ALTER TABLE users
3 | ADD COLUMN api_key VARCHAR(206) UNIQUE;
4 |
5 | CREATE EXTENSION IF NOT EXISTS pgcrypto;
6 | ALTER DATABASE nhanify SET TIMEZONE TO 'UTC';
7 | CREATE TABLE api_requests (
8 | id serial PRIMARY KEY,
9 | user_id integer NOT NULL REFERENCES users (id) ON DELETE CASCADE,
10 | create_at timestamptz NOT NULL
11 | );
--------------------------------------------------------------------------------
/views/edit_song.pug:
--------------------------------------------------------------------------------
1 | extends signup
2 | block header
3 | h1(class="pageTitle") #{pageTitle}
4 | block content
5 | form(class="inputForm" action=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}/${songId}/edit` method="post")
6 | .formDiv
7 | label(for="title") Title
8 | input(id="title" type="text" name="title" value=title || song.title)
9 | input(type="submit" class="submit" value="Save")
10 | a(href=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}`) Back
11 |
--------------------------------------------------------------------------------
/lib/pg-connect.js:
--------------------------------------------------------------------------------
1 | const { PG_PASSWORD, PG_DATABASE, PG_USER } = process.env;
2 | const { Client } = require("pg");
3 |
4 | const client = new Client({
5 | database: PG_DATABASE,
6 | password: PG_PASSWORD,
7 | user: PG_USER,
8 | });
9 |
10 | async function connectDb() {
11 | try {
12 | await client.connect();
13 | console.log("Database connected successfully");
14 | } catch (error) {
15 | console.error("Database connection failed:", error.stack);
16 | }
17 | }
18 |
19 | connectDb();
20 |
21 | module.exports = client;
22 |
--------------------------------------------------------------------------------
/views/add_contributors.pug:
--------------------------------------------------------------------------------
1 | extends signup
2 |
3 | block header
4 | h1(class="pageTitle") #{pageTitle}
5 | block content
6 | - console.log({pagePl});
7 | form(class="inputForm" action=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}/contributors/add` method="post")
8 | .formDiv
9 | label(for="username") username
10 | input(id="username" type="text" name="username" value=username)
11 | input(type="submit" class="submit" value="Add")
12 | a(href=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}`) Back
13 |
14 |
--------------------------------------------------------------------------------
/tsconfig.frontend.json:
--------------------------------------------------------------------------------
1 | // tsconfig.frontend.json
2 | {
3 | "compilerOptions": {
4 | "target": "ES2020",
5 | "module": "ESNext", // CRUCIAL: Outputs JavaScript files as native ES Modules
6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], // CRUCIAL: Allows use of browser APIs
7 |
8 | // Output Settings
9 | "outDir": "./public/assets/javascript", // CRUCIAL: Sends compiled JS files to the public folder
10 | "rootDir": "./src/frontend", // Where to find the source TS files
11 |
12 | // Safety and Cleanup
13 | "esModuleInterop": true,
14 | "strict": true,
15 | "skipLibCheck": true,
16 | "sourceMap": true // Recommended for debugging
17 | },
18 | "include": ["src/frontend/**/*"]
19 | }
20 |
--------------------------------------------------------------------------------
/views/twitch_signup.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block main
4 | .mainWrapWrap
5 | .mainWrap
6 | block header
7 | h1(class="pageTitle") Sign Up
8 | .errorMessages
9 | - let msgTypes = Object.keys(flash || {})
10 | if (msgTypes.length > 0)
11 | each msgType in msgTypes
12 | each msg in flash[msgType]
13 | pre(class=`${msgType}`) #{msg}
14 | block content
15 | p Sign up as #{twitchUsername}
16 | form(class="inputForm" action="/twitchSignup/Create" method="post")
17 | input(type="submit" class="submit" value="Sign Up")
18 | form(class="inputForm" action="/twitchSignup/Cancel" method="post")
19 | input(type="submit" class="submit" value="Cancel")
--------------------------------------------------------------------------------
/views/create_playlist.pug:
--------------------------------------------------------------------------------
1 | extends signup
2 | block header
3 | h1(class="pageTitle") Create Playlist
4 | block content
5 | form(class="inputForm" action=`/${playlistType}/playlists/${page}/create` method="post")
6 | .formDiv
7 | label(for="title") Title
8 | input(id="title" type="text" name="title" value=title)
9 | .formDiv
10 | label(for="visibility") Visibility
11 | .formDivParent
12 | .formDivHz
13 | input(type="radio" id="private" name="visibility" value="private" checked=isPrivate)
14 | label(for="private") private
15 | .formDivHz
16 | input(type="radio" id="public" name="visibility" value="public" checked=!isPrivate)
17 | label(for="public") public
18 | input(type="submit" class="submit" value="Create")
19 | a(href=`/${playlistType}/playlists/${page}`) Back
20 |
--------------------------------------------------------------------------------
/views/signup.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block main
4 | .mainWrapWrap
5 | .mainWrap
6 | block header
7 | h1(class="pageTitle") Sign Up
8 | .errorMessages
9 | - let msgTypes = Object.keys(flash || {})
10 | if (msgTypes.length > 0)
11 | each msgType in msgTypes
12 | each msg in flash[msgType]
13 | pre(class=`${msgType}`) #{msg}
14 | block content
15 | form(class="inputForm" action="/signup" method="post")
16 | .formDiv
17 | label(for="username") Username
18 | input( id="username" type="text" name="username" value=username)
19 | .formDiv
20 | label(for="password") Password
21 | input(id="password" type="password" name="password" value=password)
22 | input(type="submit" class="submit" value="Sign Up")
23 |
24 |
25 |
--------------------------------------------------------------------------------
/lib/playlist.test.js:
--------------------------------------------------------------------------------
1 | const { parseURL, isValidURL } = require("./playlist.js");
2 |
3 | test.each([
4 | ["https://www.youtube.com/watch?v=_QkGAaYtXA0", "_QkGAaYtXA0"],
5 | ["https://youtu.be/_QkGAaYtXA0?si=ypKoIW-8nJ2grUHZ", "_QkGAaYtXA0"],
6 | ["https://m.youtube.com/watch?v=rlaNMJeA1EA", "rlaNMJeA1EA"],
7 | ])("Parses %p to %p.", (url, expected) => {
8 | expect(parseURL(url)).toBe(expected);
9 | });
10 |
11 | test.each([
12 | ["https://www.youtube.com/watch?v=_QkGAaYtXA0", true],
13 | ["https://youtu.be/_QkGAaYtXA0?si=ypKoIW-8nJ2grUHZ", true],
14 | ["https://m.youtube.com/watch?v=rlaNMJeA1EA", true],
15 | ["https://m.youtube.com/watch?v=rlaNMJeA1EA?v=rlaNMJeA1EA", true],
16 | ["https://m.youtube.com/watch?v=huh", true],
17 | ["https://m.notyoutube.com/watch?v=rlaNMJeA1EA", false],
18 | ["socks://m.youtube.com/watch?v=rlaNMJeA1EA", false],
19 | ])("%p is valid: %p.", (url, expected) => {
20 | expect(isValidURL(url)).toBe(expected);
21 | });
22 |
--------------------------------------------------------------------------------
/views/edit_playlist.pug:
--------------------------------------------------------------------------------
1 | extends signup
2 | block header
3 | h1(class="pageTitle") #{pageTitle}
4 | block content
5 | form(class="inputForm" action=`/${playlistType}/playlists/${page}/playlist/${playlistId}/edit` method="post")
6 | .formDiv
7 | label(for="title") Title
8 | input(id="title" type="text" name="title" value=title || playlist.title)
9 | .formDiv
10 | label(for="visiability") Visibility
11 | .formDivParent
12 | .formDivHz
13 | - let vis = playlist.private ? "private" : "public";
14 | input(type="radio" id="private" name="visiability" value="private" checked=visiability || vis === "private")
15 | label(for="private") private
16 | .formDivHz
17 | input(type="radio" id="public" name="visiability" value="public" checked=visiability || vis === "public")
18 | label(for="public") public
19 | input(type="submit" class="submit" value="Save")
20 | a(href=`/${playlistType}/playlists/${page}`) Back
21 |
--------------------------------------------------------------------------------
/myserver.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Nhanify
3 | # Documentation=https://
4 | # Author: Natan Cabral
5 | [Service]
6 | # Start Service and Examples
7 | ExecStart=/home/ubuntu/.nvm/versions/node/v21.7.3/bin/node app.js
8 | # ExecStart=/usr/bin/sudo /usr/bin/node /home/myserver/server.js
9 | # ExecStart=/usr/local/bin/node /var/www/project/myserver/server.js
10 | # Options Stop and Restart
11 | # ExecStop=
12 | # ExecReload=
13 | # Required on some systems
14 | WorkingDirectory=/home/ubuntu/nhanify
15 | #WorkingDirectory=/var/www/myproject/
16 | # Restart service after 10 seconds if node service crashes
17 | RestartSec=10
18 | Restart=always
19 | # Restart=on-failure
20 | # Output to syslog
21 | StandardOutput=syslog
22 | StandardError=syslog
23 | SyslogIdentifier=nhanify
24 | # #### please, not root users
25 | # RHEL/Fedora uses 'nobody'
26 | User=ubuntu
27 | # Debian/Ubuntu uses 'nogroup', RHEL/Fedora uses 'nobody'
28 | # Group=nogroup
29 | # variables
30 | EnvironmentFile=/home/ubuntu/nhanify/.env
31 | # Environment=NODE_ENV=production
32 | # Environment=NODE_PORT=3001
33 | # Environment="SECRET=pGNqduRFkB4K9C2vijOmUDa2kPtUhArN"
34 | # Environment="ANOTHER_SECRET=JP8YLOc2bsNlrGuD6LVTq7L36obpjzxd"
35 | [Install]
36 | WantedBy=multi-user.target
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Prerequisites
2 |
3 | - Node.js >= 21.7.1
4 | - Chrome >= 127.0.6533.119
5 | - PostgreSQL >= 14.13
6 |
7 | ## Setup
8 |
9 | 1. **Systemd**: Copy `myserver.service` to your systemd folder (if you want to host the application on a remote or local server).
10 | 2. **Install Node.js & PostgreSQL**: Ensure both are installed, with `npm` and `psql` available.
11 | 3. **Install Dependencies**: Run `npm install` in the project root.
12 | 4. **Create DB**: Run `createdb `, and add it to `PG_DATABASE` in `.env`.
13 | 5. **Configure Environment**:
14 | - Copy `.env.example` to `.env`.
15 | - Update DB details in `.env`.
16 | 6. **Setup DB**: Run `psql -d < lib/schema_data.sql` to load schema and seed data.
17 | 7. **Start App**: Run `npm start`.
18 | 8. **Access**: Open Chrome at the `HOST:PORT` defined in `.env`.
19 |
20 | ## Application Overview
21 |
22 | This app lets users share and collaborate on playlists, with song playback via the YouTube Iframe API.
23 |
24 | ### Features
25 |
26 | 1. **Public Playlists**: Lists all public playlists.
27 | 2. **Your Playlists**:
28 | - View, create, edit, delete playlists.
29 | - Add/remove contributors (must be registered users).
30 | 3. **Contribution Playlists**:
31 | - Manage playlists where you're a contributor (add, delete, edit songs).
32 | - Accepts specific YouTube URLs:
33 | - `https://www.youtube.com/watch?v=...`
34 | - `https://youtu.be/...`
35 | - `https://m.youtube.com/watch?v=...`
36 |
--------------------------------------------------------------------------------
/lib/schema.sql:
--------------------------------------------------------------------------------
1 | DROP TABLE IF EXISTS playlists_users;
2 |
3 | DROP TABLE IF EXISTS songs;
4 |
5 | DROP TABLE IF EXISTS playlists;
6 |
7 | DROP TABLE IF EXISTS users;
8 |
9 | --create a users table
10 | CREATE TABLE users (
11 | id serial PRIMARY KEY,
12 | username text NOT NULL,
13 | password text,
14 | CONSTRAINT unique_username UNIQUE (username)
15 | );
16 |
17 | --create playlists table schema
18 | CREATE TABLE playlists (
19 | id serial PRIMARY KEY,
20 | title text NOT NULL,
21 | creator_id integer NOT NULL REFERENCES users (id),
22 | private boolean NOT NULL DEFAULT true,
23 | CONSTRAINT unique_creator_id_title UNIQUE (creator_id, title)
24 | );
25 |
26 | --create songs table schema
27 | CREATE TABLE songs (
28 | id serial PRIMARY KEY,
29 | title text NOT NULL,
30 | video_id text NOT NULL,
31 | playlist_id integer NOT NULL REFERENCES playlists (id) ON DELETE CASCADE,
32 | creator_id integer NOT NULL REFERENCES users (id) ON DELETE CASCADE,
33 | CONSTRAINT unique_video_id_playlist_id UNIQUE (playlist_id, video_id),
34 | CONSTRAINT unique_title_playlist_id UNIQUE (playlist_id, title)
35 | );
36 |
37 | --create a playlists_users table that references playlists and users
38 | CREATE TABLE playlists_users (
39 | id serial PRIMARY KEY,
40 | playlist_id integer NOT NULL REFERENCES playlists (id) ON DELETE CASCADE,
41 | contributor_id integer NOT NULL REFERENCES users (id) ON DELETE CASCADE,
42 | CONSTRAINT unique_playlist_id_user_id UNIQUE (playlist_id, contributor_id)
43 | );
44 |
--------------------------------------------------------------------------------
/migrations/20250306182624-api-key-system.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var dbm;
4 | var type;
5 | var seed;
6 | var fs = require('fs');
7 | var path = require('path');
8 | var Promise;
9 |
10 | /**
11 | * We receive the dbmigrate dependency from dbmigrate initially.
12 | * This enables us to not have to rely on NODE_PATH.
13 | */
14 | exports.setup = function(options, seedLink) {
15 | dbm = options.dbmigrate;
16 | type = dbm.dataType;
17 | seed = seedLink;
18 | Promise = options.Promise;
19 | };
20 |
21 | exports.up = function(db) {
22 | var filePath = path.join(__dirname, 'sqls', '20250306182624-api-key-system-up.sql');
23 | return new Promise( function( resolve, reject ) {
24 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
25 | if (err) return reject(err);
26 | console.log('received data: ' + data);
27 |
28 | resolve(data);
29 | });
30 | })
31 | .then(function(data) {
32 | return db.runSql(data);
33 | });
34 | };
35 |
36 | exports.down = function(db) {
37 | var filePath = path.join(__dirname, 'sqls', '20250306182624-api-key-system-down.sql');
38 | return new Promise( function( resolve, reject ) {
39 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
40 | if (err) return reject(err);
41 | console.log('received data: ' + data);
42 |
43 | resolve(data);
44 | });
45 | })
46 | .then(function(data) {
47 | return db.runSql(data);
48 | });
49 | };
50 |
51 | exports._meta = {
52 | "version": 1
53 | };
54 |
--------------------------------------------------------------------------------
/migrations/20250304181536-add-twitch-id-on-user.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var dbm;
4 | var type;
5 | var seed;
6 | var fs = require('fs');
7 | var path = require('path');
8 | var Promise;
9 |
10 | /**
11 | * We receive the dbmigrate dependency from dbmigrate initially.
12 | * This enables us to not have to rely on NODE_PATH.
13 | */
14 | exports.setup = function(options, seedLink) {
15 | dbm = options.dbmigrate;
16 | type = dbm.dataType;
17 | seed = seedLink;
18 | Promise = options.Promise;
19 | };
20 |
21 | exports.up = function(db) {
22 | var filePath = path.join(__dirname, 'sqls', '20250304181536-add-twitch-id-on-user-up.sql');
23 | return new Promise( function( resolve, reject ) {
24 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
25 | if (err) return reject(err);
26 | console.log('received data: ' + data);
27 |
28 | resolve(data);
29 | });
30 | })
31 | .then(function(data) {
32 | return db.runSql(data);
33 | });
34 | };
35 |
36 | exports.down = function(db) {
37 | var filePath = path.join(__dirname, 'sqls', '20250304181536-add-twitch-id-on-user-down.sql');
38 | return new Promise( function( resolve, reject ) {
39 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
40 | if (err) return reject(err);
41 | console.log('received data: ' + data);
42 |
43 | resolve(data);
44 | });
45 | })
46 | .then(function(data) {
47 | return db.runSql(data);
48 | });
49 | };
50 |
51 | exports._meta = {
52 | "version": 1
53 | };
54 |
--------------------------------------------------------------------------------
/migrations/20250304181247-update-username-on-user.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var dbm;
4 | var type;
5 | var seed;
6 | var fs = require('fs');
7 | var path = require('path');
8 | var Promise;
9 |
10 | /**
11 | * We receive the dbmigrate dependency from dbmigrate initially.
12 | * This enables us to not have to rely on NODE_PATH.
13 | */
14 | exports.setup = function(options, seedLink) {
15 | dbm = options.dbmigrate;
16 | type = dbm.dataType;
17 | seed = seedLink;
18 | Promise = options.Promise;
19 | };
20 |
21 | exports.up = function(db) {
22 | var filePath = path.join(__dirname, 'sqls', '20250304181247-update-username-on-user-up.sql');
23 | return new Promise( function( resolve, reject ) {
24 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
25 | if (err) return reject(err);
26 | console.log('received data: ' + data);
27 |
28 | resolve(data);
29 | });
30 | })
31 | .then(function(data) {
32 | return db.runSql(data);
33 | });
34 | };
35 |
36 | exports.down = function(db) {
37 | var filePath = path.join(__dirname, 'sqls', '20250304181247-update-username-on-user-down.sql');
38 | return new Promise( function( resolve, reject ) {
39 | fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){
40 | if (err) return reject(err);
41 | console.log('received data: ' + data);
42 |
43 | resolve(data);
44 | });
45 | })
46 | .then(function(data) {
47 | return db.runSql(data);
48 | });
49 | };
50 |
51 | exports._meta = {
52 | "version": 1
53 | };
54 |
--------------------------------------------------------------------------------
/migrations/20240918190940-add-duration.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | /* eslint-disable */
3 |
4 | var dbm;
5 | var type;
6 | var seed;
7 | var fs = require("fs");
8 | var path = require("path");
9 | var Promise;
10 |
11 | /**
12 | * We receive the dbmigrate dependency from dbmigrate initially.
13 | * This enables us to not have to rely on NODE_PATH.
14 | */
15 | exports.setup = function (options, seedLink) {
16 | dbm = options.dbmigrate;
17 | type = dbm.dataType;
18 | seed = seedLink;
19 | Promise = options.Promise;
20 | };
21 |
22 | exports.up = function (db) {
23 | var filePath = path.join(
24 | __dirname,
25 | "sqls",
26 | "20240918190940-add-duration-up.sql",
27 | );
28 | return new Promise(function (resolve, reject) {
29 | fs.readFile(filePath, { encoding: "utf-8" }, function (err, data) {
30 | if (err) return reject(err);
31 | console.log("received data: " + data);
32 |
33 | resolve(data);
34 | });
35 | }).then(function (data) {
36 | return db.runSql(data);
37 | });
38 | };
39 |
40 | exports.down = function (db) {
41 | var filePath = path.join(
42 | __dirname,
43 | "sqls",
44 | "20240918190940-add-duration-down.sql",
45 | );
46 | return new Promise(function (resolve, reject) {
47 | fs.readFile(filePath, { encoding: "utf-8" }, function (err, data) {
48 | if (err) return reject(err);
49 | console.log("received data: " + data);
50 |
51 | resolve(data);
52 | });
53 | }).then(function (data) {
54 | return db.runSql(data);
55 | });
56 | };
57 |
58 | exports._meta = {
59 | version: 1,
60 | };
61 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import globals from "globals";
2 | import pluginJs from "@eslint/js";
3 | import pluginJest from "eslint-plugin-jest";
4 |
5 | export default [
6 | {
7 | ignores: [
8 | "**/*.sql",
9 | "migrations/*",
10 | "transpiledjs/*",
11 | "public/assets/javascript/**/*",
12 | "dist/**/*",
13 | ], // Add this line to ignore SQL files
14 | },
15 | {
16 | files: ["**/*.js"],
17 | languageOptions: {
18 | sourceType: "commonjs",
19 | globals: {
20 | ...globals.node,
21 | },
22 | },
23 | },
24 | // Add this configuration for ES modules (your compiled TypeScript)
25 | {
26 | files: ["public/assets/javascript/**/*.js"], // Target compiled files
27 | languageOptions: {
28 | sourceType: "module", // This is the key fix
29 | ecmaVersion: 2020,
30 | globals: {
31 | ...globals.browser,
32 | },
33 | },
34 | },
35 | {
36 | rules: {
37 | "no-unused-vars": [
38 | "error",
39 | {
40 | argsIgnorePattern: "^_",
41 | },
42 | ],
43 | },
44 | },
45 | {
46 | files: ["**/*.{test,spec}.js"],
47 | plugins: {
48 | jest: pluginJest,
49 | },
50 | languageOptions: {
51 | globals: {
52 | ...globals.jest,
53 | },
54 | },
55 | rules: {
56 | ...pluginJest.configs.recommended.rules,
57 | },
58 | },
59 | {
60 | files: ["**/*.js"],
61 | languageOptions: {
62 | globals: {
63 | ...globals.browser,
64 | },
65 | },
66 | },
67 | pluginJs.configs.recommended,
68 | ];
69 |
--------------------------------------------------------------------------------
/Utilities/addSongDuration.js:
--------------------------------------------------------------------------------
1 | const { YT_API_KEY } = process.env;
2 |
3 | // import in the db client and connect to the database
4 | const { setTimeout } = require("timers/promises");
5 | console.log({ setTimeout });
6 | const client = require("../lib/pg-connect.js");
7 | const { getVidInfoByVidId } = require("../lib/playlist.js");
8 |
9 | // query the db for all songs where the duration is null
10 | async function getNullDurSong() {
11 | const result = await client.query(
12 | "SELECT DISTINCT video_id FROM songs WHERE duration_sec IS NULL",
13 | //"SELECT DISTINCT video_id FROM songs",
14 | );
15 | return result.rows;
16 | }
17 | (async () => {
18 | const songs = await getNullDurSong();
19 |
20 | console.log(songs);
21 | // iterate through the songs and get the video id and pass to the YT Data api
22 | for (const song of songs) {
23 | // connecting to the YOUTUBE Data API to grab the duration and return an object containing duration
24 | const vidInfo = await getVidInfoByVidId(song.video_id, YT_API_KEY); //vidInfo.ducation => sec
25 | await setTimeout(100);
26 | console.log(`Title: ${vidInfo.title}`);
27 | await updateSongDuration(song.video_id, vidInfo.durationSecs);
28 | }
29 | console.log("FINISHED");
30 | await client.end();
31 | })();
32 |
33 | // insert the duration into the specific record associated with the video id.
34 | async function updateSongDuration(videoId, duration) {
35 | try {
36 | await client.query(
37 | "UPDATE songs SET duration_sec = $1 WHERE video_id = $2",
38 | [duration, videoId],
39 | ); //dont bother batching, just blast this bitch fast af boi
40 | console.log(`${duration} was added to ${videoId} `);
41 | } catch (err) {
42 | console.error(`Error updating song ${videoId}`, err);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/views/public_playlists.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block main
4 | if (playlistTotal === 0)
5 | .emptyMessage
6 | p There is currently no public playlist.
7 | else
8 | .playlistsDiv
9 | h1(class="pageTitle") Public Playlists
10 | block listWrap
11 | block list
12 | .listHeader
13 | - let playlistLabel = playlistTotal < 2 ? "Playlist" : "Playlists"
14 | p(class="username") #{playlistTotal} #{playlistLabel}
15 | .pageNav
16 | if (page > 1)
17 | a(href=`/anon/public/playlists/${page - 1}`)
18 | img(src=`/assets/icons/arrow.svg`)
19 | .pages
20 | - for (let pageNum = startPage ; pageNum <= endPage; pageNum++)
21 | a(class=`${(pageNum === page) ? "curPage" : ""}` href=`/anon/public/playlists/${pageNum}`) #{pageNum}
22 | if (page < totalPages)
23 | a(href=`/anon/public/playlists/${page + 1}`)
24 | img(src=`/assets/icons/arrow.svg` class="rotate180")
25 | .cols
26 | .labelTitle
27 | p(class="username") Title
28 | .labelAddedBy
29 | p(class="username") Creator
30 | .labelSongTotal
31 | p(class="username") Songs
32 | block values
33 | .content
34 | .playListWrap
35 | each playlist in playlists
36 | a(href=`/anon/public/playlists/${page}/playlist/1/${playlist.id}`)
37 | div(class="songCard")
38 | .descriptionDiv
39 | .valTitle
40 | p= playlist.title
41 | .valAddedBy
42 | p= playlist.username
43 | .valSongTotal
44 | p= playlist.count
45 |
--------------------------------------------------------------------------------
/public/assets/css/reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html,
7 | body,
8 | div,
9 | span,
10 | applet,
11 | object,
12 | iframe,
13 | h1,
14 | h2,
15 | h3,
16 | h4,
17 | h5,
18 | h6,
19 | p,
20 | blockquote,
21 | pre,
22 | a,
23 | abbr,
24 | acronym,
25 | address,
26 | big,
27 | cite,
28 | code,
29 | del,
30 | dfn,
31 | em,
32 | img,
33 | ins,
34 | kbd,
35 | q,
36 | s,
37 | samp,
38 | small,
39 | strike,
40 | strong,
41 | sub,
42 | sup,
43 | tt,
44 | var,
45 | b,
46 | u,
47 | i,
48 | center,
49 | dl,
50 | dt,
51 | dd,
52 | ol,
53 | ul,
54 | li,
55 | fieldset,
56 | form,
57 | label,
58 | legend,
59 | table,
60 | caption,
61 | tbody,
62 | tfoot,
63 | thead,
64 | tr,
65 | th,
66 | td,
67 | article,
68 | aside,
69 | canvas,
70 | details,
71 | embed,
72 | figure,
73 | figcaption,
74 | footer,
75 | header,
76 | hgroup,
77 | menu,
78 | nav,
79 | output,
80 | ruby,
81 | section,
82 | summary,
83 | time,
84 | mark,
85 | audio,
86 | video {
87 | margin: 0;
88 | padding: 0;
89 | border: 0;
90 | font-size: 100%;
91 | font: inherit;
92 | vertical-align: baseline;
93 | }
94 | /* HTML5 display-role reset for older browsers */
95 | article,
96 | aside,
97 | details,
98 | figcaption,
99 | figure,
100 | footer,
101 | header,
102 | hgroup,
103 | menu,
104 | nav,
105 | section {
106 | display: block;
107 | }
108 | body {
109 | line-height: 1;
110 | }
111 | ol,
112 | ul {
113 | list-style: none;
114 | }
115 | blockquote,
116 | q {
117 | quotes: none;
118 | }
119 | blockquote:before,
120 | blockquote:after,
121 | q:before,
122 | q:after {
123 | content: "";
124 | content: none;
125 | }
126 | table {
127 | border-collapse: collapse;
128 | border-spacing: 0;
129 | }
130 |
--------------------------------------------------------------------------------
/views/layout.pug:
--------------------------------------------------------------------------------
1 | doctype html
2 | html(lang="en")
3 | head
4 | title Nhanify
5 | meta(charset="UTF-8" name="viewport", content="width=device-width, initial-scale=1.0")
6 | link(rel="stylesheet" href="/assets/css/reset.css")
7 | link(rel="stylesheet" href="/assets/css/myStyle.css")
8 | link(rel="preconnect" href="https://fonts.googleapis.com")
9 | link(rel="preconnect" href="https://fonts.gstatic.com" crossorigin)
10 | link(href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet")
11 | script(type="module" src="/assets/javascript/index.js" defer)
12 | body
13 | .bWrap
14 | .navTopWrap
15 | .stack1
16 | .navTopLeft
17 | .home
18 | a(class="logo" href="/your/playlists/1") N
19 |
20 | if (user !== undefined)
21 | p(class="username") #{user.username}
22 | .navTopRight
23 | if (user !== undefined)
24 | form(class="buttonForm" action="/signout" method="post")
25 | input(type="submit" value="Sign Out")
26 | else
27 | a(href="/signin") Sign In
28 | .stack2
29 | if (user === undefined)
30 | if (playlistType === "anonPublic")
31 | a(class="hiNavTab" href=`/anon/public/playlists/1`) Public Playlists
32 | else
33 | a(class="navTab" href=`/anon/public/playlists/1`) Public Playlists
34 | else
35 | if (playlistType === "public")
36 | a(class="hiNavTab" href=`/public/playlists/1`) Public Playlists
37 | else
38 | a(class="navTab" href=`/public/playlists/1`) Public Playlists
39 | if (playlistType === "your")
40 | a(class="hiNavTab" href="/your/playlists/1") Your Playlists
41 | else
42 | a(class="navTab" href="/your/playlists/1") Your Playlists
43 | if (playlistType === "contribution")
44 | a(class="hiNavTab" href="/contribution/playlists/1") Contribution Playlists
45 | else
46 | a(class="navTab" href="/contribution/playlists/1") Contribution Playlists
47 | main
48 | block main
49 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nhanify",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "app.js",
6 | "directories": {
7 | "lib": "lib"
8 | },
9 | "scripts": {
10 | "prettier": "npx prettier . --write",
11 | "lint": "npx eslint . --ignore-pattern 'backup.sql' --fix .",
12 | "test": "jest --verbose --detectOpenHandles",
13 | "start": "nodemon --env-file=.env app.js",
14 | "make-db": "psql -d nhanify < lib/schema.sql && psql -d nhanify < migrations/sqls/20240918190940-add-duration-up.sql && psql -d nhanify < lib/seed.sql",
15 | "make-schema": "psql -d nhanify < lib/schema.sql",
16 | "make-seed": "psql -d nhanify < lib/seed.sql",
17 | "start-ec2": "ssh nhanify 'sudo systemctl start myserver.service'",
18 | "stop-ec2": "ssh nhanify 'sudo systemctl stop myserver.service'",
19 | "status-ec2": "ssh nhanify 'sudo systemctl status myserver.service'",
20 | "deploy-ec2": "ssh -t nhanify 'bash -i -c \"cd nhanify && git pull && npm install && npx db-migrate up --env pg && sudo systemctl restart myserver.service\"'",
21 | "prepare": "husky",
22 | "migrate-up": "npx db-migrate up -e pg",
23 | "migrate-down": "npx db-migrate down -e pg"
24 | },
25 | "author": "",
26 | "license": "ISC",
27 | "dependencies": {
28 | "@sentry/cli": "^2.45.0",
29 | "@sentry/node": "^9.19.0",
30 | "bcrypt": "^5.1.1",
31 | "body-parser": "^1.20.2",
32 | "connect-loki": "^1.2.0",
33 | "db-migrate": "^0.11.14",
34 | "db-migrate-pg": "^1.5.2",
35 | "express": "^4.18.2",
36 | "express-flash": "^0.0.2",
37 | "express-session": "^1.18.0",
38 | "express-validator": "^7.1.0",
39 | "nhanify": "file:",
40 | "pg": "^8.12.0",
41 | "pug": "^3.0.2",
42 | "uuid": "^11.1.0"
43 | },
44 | "devDependencies": {
45 | "@eslint/js": "^9.11.1",
46 | "@types/node": "^22.15.29",
47 | "@types/web": "^0.0.272",
48 | "@types/youtube": "^0.1.2",
49 | "babel-eslint": "^10.1.0",
50 | "dotenv": "^16.4.5",
51 | "eslint": "^9.11.1",
52 | "eslint-cli": "^1.1.1",
53 | "eslint-plugin-jest": "^28.8.3",
54 | "globals": "^15.9.0",
55 | "husky": "^9.1.6",
56 | "jest": "^29.7.0",
57 | "morgan": "^1.10.0",
58 | "nodemon": "^3.1.0",
59 | "prettier": "^3.2.5",
60 | "prettier-plugin-sql": "^0.18.0",
61 | "typescript": "^5.8.3"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/views/contributors.pug:
--------------------------------------------------------------------------------
1 | extends playlists
2 |
3 | block listWrap
4 | if (totalContributors === 0 )
5 | .errorMessages
6 | - let msgTypes = Object.keys(flash || {})
7 | if (msgTypes.length > 0)
8 | each msgType in msgTypes
9 | each msg in flash[msgType]
10 | pre(class=`${msgType}`) #{msg}
11 | p There are no contributors on this playlist.
12 | a(href=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}`) Back
13 | else
14 | block list
15 | .listHeader
16 | p(class="username") #{totalContributors} Contributor(s)
17 | .errorMessages
18 | - let msgTypes = Object.keys(flash || {})
19 | if (msgTypes.length > 0)
20 | each msgType in msgTypes
21 | each msg in flash[msgType]
22 | pre(class=`${msgType}`) #{msg}
23 | .pageNav
24 | if (pageCb > 1)
25 | a(href=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}/contributors/${pageCb - 1}`)
26 | img(src=`/assets/icons/arrow.svg`)
27 | .pages
28 | - for (let pageNum = startPage ; pageNum <= endPage; pageNum++)
29 | a(class=`${(pageNum === pageCb) ? "curPage" : ""}` href=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}/contributors/${pageNum}`) #{pageNum}
30 | if (pageCb < totalPages)
31 | a(href=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}/contributors/${pageCb + 1}`)
32 | img(src=`/assets/icons/arrow.svg` class="rotate180")
33 | .cols
34 | .label
35 | p(class="username") username
36 | .label
37 | block values
38 | .content
39 | .playListWrap
40 | each contributor in contributors.contributors
41 | div(class="songCard")
42 | .val
43 | p= contributor.username
44 | .val
45 | if(playlistType === "your")
46 | form(class="delete" action=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}/contributors/${pageCb}/${contributor.id}/delete` method="post")
47 | input(onclick= "confirmSubmit(event, 'Delete contributor from the playlist?')" class="deleteBtn" type="submit" value="")
48 | a(href=`/${playlistType}/playlists/${page}/playlist/${pagePl}/${playlistId}`) Back
49 |
50 |
--------------------------------------------------------------------------------
/lib/msg.json:
--------------------------------------------------------------------------------
1 | {
2 | "invalidVideoId": "Looks like we can't find your video. Please check your video id in your Youtube URL and try again.",
3 | "error401": "Oops! Your session has ended. Please log back into your account.",
4 | "alreadyLoggedIn": "You are already logged in.",
5 | "error500": "Oops! Something went wrong with the server. We are currently addressing the issue.",
6 | "error404": "Oops! It looks like the page does not exist.",
7 | "errorNav": "Click on the playlist links above to navigate back to your previous page.",
8 | "error403": "Oops! It looks like you don’t have access to this page.",
9 | "addedSong": "Song was added to the playlist.",
10 | "editedSong": "Song was edited.",
11 | "deleteSong": "Song was deleted from the playlist.",
12 | "overPlaylistsLimit": "You have reach over allowed number of playlists.",
13 | "overSongsLimit": "You have reach over allowed number of songs.",
14 | "uniqueSong": "Song already exists on the playlist.",
15 | "uniquePlaylist": "Playlist title already exists.",
16 | "uniqueSongTitle": "Song title already exists.",
17 | "invalidURL": "Invalid URL.",
18 | "emptyUsername": "Username is empty.",
19 | "emptyPassword": "Password is empty.",
20 | "maxUsername": "Username is over the maxium of 30 characters.",
21 | "notExistUsername": "Username does not exist.",
22 | "notExistPlaylist": "Playlist does not exist.",
23 | "uniqueContributor": "Username is already a contributor.",
24 | "addContributor": "Contributor was added to the playlist.",
25 | "unknownDbError": "Sorry something went wrong with your request. Please try again.",
26 | "unauthorizedUser": "You are not authorized to access the information requested.",
27 | "deletePlaylist": "Playlist was deleted.",
28 | "createPlaylist": "Playlist was created.",
29 | "createPlaylistError": "Sorry, Something went wrong while creating your playlist. Try again another time.",
30 | "deleteContributor": "Contributor was deleted.",
31 | "loggedIn": "You are logged in.",
32 | "signout": "You are logged out.",
33 | "invalidCred": "Credentials are invalid.",
34 | "minPassword": "Password is under the minimum of 12 characters.",
35 | "maxPassword": "Password is over the maxium of 72 characters.",
36 | "uniqueUsername": "Username is already taken.",
37 | "createUser": "User account was created.",
38 | "creatorContributor": "Creator of the playlist can not be a contributor.",
39 | "playlistEdited": "Playlist was edited.",
40 | "notExistSong": "Song does not exist on playlist."
41 | }
42 |
--------------------------------------------------------------------------------
/Utilities/addTwitchId.js:
--------------------------------------------------------------------------------
1 | const { CLIENT_SECRET, CLIENT_ID, BOT_REFRESH_TOKEN } = process.env;
2 |
3 | // import in the db client and connect to the database
4 | const client = require("../lib/pg-connect.js");
5 | async function usersName() {
6 | const result = await client.query("SELECT username FROM users ORDER BY id");
7 | return result.rows;
8 | }
9 | const batchSize = 100; //99 user at time // 100 user ...
10 | async function run(BOT_TWITCH_TOKEN, CLIENT_ID, params) {
11 | for (let i = 0; i < params.length; i += batchSize) {
12 | let batchParams = params.slice(i, i + batchSize).join("&");
13 | const result = await getUserTwitchId(
14 | batchParams,
15 | BOT_TWITCH_TOKEN,
16 | CLIENT_ID,
17 | );
18 | console.log({ i, batchParams, result });
19 | await populateTwitchUserId(result);
20 | }
21 | }
22 |
23 | async function populateTwitchUserId(result) {
24 | const promises = result.data.map((user) => {
25 | //find where the user record with the login
26 | const UPDATE_TWITCH_ID = `UPDATE users SET twitch_id = $1 WHERE username = $2 AND twitch_id IS NULL`;
27 | return client.query(UPDATE_TWITCH_ID, [user.id, user.display_name]);
28 | });
29 | return await Promise.all(promises);
30 | }
31 |
32 | async function getUserTwitchId(params, TWITCH_TOKEN, CLIENT_ID) {
33 | try {
34 | const response = await fetch(
35 | `https://api.twitch.tv/helix/users?${params}`,
36 | {
37 | method: "GET",
38 | headers: {
39 | Authorization: "Bearer " + TWITCH_TOKEN,
40 | "Client-Id": CLIENT_ID,
41 | },
42 | },
43 | );
44 | const body = await response.json();
45 | if (response.status === 200) {
46 | return body;
47 | } else {
48 | console.error(`${JSON.stringify(body)}`);
49 | }
50 | } catch (e) {
51 | console.error(e);
52 | }
53 | }
54 | async function refreshAuthToken(entity, BOT_REFRESH_TOKEN) {
55 | const payload = {
56 | grant_type: "refresh_token",
57 | refresh_token: BOT_REFRESH_TOKEN,
58 | client_id: CLIENT_ID,
59 | client_secret: CLIENT_SECRET,
60 | };
61 | console.log({ CLIENT_ID, CLIENT_SECRET, BOT_REFRESH_TOKEN });
62 | try {
63 | const response = await fetch("https://id.twitch.tv/oauth2/token", {
64 | method: "POST",
65 | headers: { "Content-Type": "application/x-www-form-urlencoded" },
66 | body: new URLSearchParams(payload).toString(),
67 | });
68 | const body = await response.json();
69 | if (response.status === 200) {
70 | console.log(`${response.status}: Refresh ${entity} token.`);
71 | return { type: "data", body };
72 | }
73 | return { type: "error", body };
74 | } catch (e) {
75 | console.error(e);
76 | return {
77 | type: "error",
78 | body: { message: "Something went wrong when refreshing token" },
79 | };
80 | }
81 | }
82 | (async () => {
83 | const data = await refreshAuthToken("bot", BOT_REFRESH_TOKEN);
84 | const usersNameData = await usersName();
85 | const params = usersNameData.map((user) => `login=${user.username}`); //Orshy
86 | await run(data.body.access_token, CLIENT_ID, params);
87 | console.log("FINISHED");
88 | await client.end();
89 | })();
90 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | const { HOST, PORT, SESSION_SECRET, NODE_ENV } = process.env;
2 | // IMPORTANT: Make sure to import `instrument.js` at the top of your file.
3 | // If you're using ECMAScript Modules (ESM) syntax, use `import "./instrument.js";`
4 | const isProduction = NODE_ENV === "production";
5 | let Sentry;
6 | if (isProduction) {
7 | require("./lib/instrument.js");
8 | // All other imports below
9 | // Import with `import * as Sentry from "@sentry/node"` if you are using ESM
10 | Sentry = require("@sentry/node");
11 | }
12 |
13 | const express = require("express");
14 | const app = express();
15 | const session = require("express-session");
16 | const store = require("connect-loki");
17 | const LokiStore = store(session);
18 | const morgan = require("morgan");
19 | const { NotFoundError, ForbiddenError } = require("./lib/errors.js");
20 | const flash = require("express-flash");
21 | const Persistence = require("./lib/pg-persistence.js");
22 | app.locals.persistence = new Persistence();
23 | const MSG = require("./lib/msg.json");
24 | const { playlistsRouter } = require("./routes/playlistsRouter.js");
25 | const { songsRouter } = require("./routes/songsRouter.js");
26 | const { contributorsRouter } = require("./routes/contributorsRouter.js");
27 | const { authRouter } = require("./routes/authRouter.js");
28 | const { apiRouter } = require("./routes/apiRouter.js");
29 | app.set("views", "./views");
30 | app.set("view engine", "pug");
31 | app.use(express.static("public"));
32 | app.use(morgan("common"));
33 | app.use(express.urlencoded({ extended: true }));
34 | app.use(
35 | session({
36 | cookie: {
37 | httpOnly: true,
38 | maxAge: 3_600_000 * 24,
39 | path: "/",
40 | secure: false,
41 | },
42 | name: "nhanify-id",
43 | resave: false,
44 | saveUninitialized: true,
45 | secret: SESSION_SECRET,
46 | store: new LokiStore({}),
47 | }),
48 | );
49 | app.use(flash());
50 | app.use((req, res, next) => {
51 | if (req.url === "/favicon.ico") return res.status(204).end();
52 | res.locals.user = req.session.user;
53 | res.locals.flash = req.session.flash;
54 | delete req.session.flash;
55 | next();
56 | });
57 | app.use("/api", apiRouter);
58 | app.use(authRouter);
59 | app.use(playlistsRouter);
60 | app.use(songsRouter);
61 | app.use(contributorsRouter);
62 |
63 | // The error handler must be registered before any other error middleware and after all controllers
64 | if (isProduction) Sentry.setupExpressErrorHandler(app);
65 |
66 | // error handlers
67 | app.use("*", (req, res, next) => {
68 | next(new NotFoundError());
69 | });
70 | app.use((err, req, res, _next) => {
71 | // do not remove next parameter
72 | console.log(err);
73 | if (err instanceof ForbiddenError) {
74 | res.status(403);
75 | res.render("error", {
76 | statusCode: 403,
77 | msg: MSG.error403,
78 | msg2: MSG.errorNav,
79 | });
80 | } else if (err instanceof NotFoundError) {
81 | res.status(404);
82 | res.render("error", {
83 | statusCode: 404,
84 | msg: MSG.error404,
85 | msg2: MSG.errorNav,
86 | });
87 | } else {
88 | if (isProduction) Sentry.captureException(err);
89 | res.status(500);
90 | res.render("error", { statusCode: 500, msg: MSG.error500 });
91 | }
92 | });
93 |
94 | app.listen(PORT, HOST, () => {
95 | console.log(`🎵 Nhanify music ready to rock on http://${HOST}:${PORT} 🎵`);
96 | });
97 |
--------------------------------------------------------------------------------
/.github/workflows/node.js.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
3 |
4 | name: Node.js CI
5 |
6 | on: [push]
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 |
11 | strategy:
12 | matrix:
13 | node-version: [21.x]
14 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
15 |
16 | # Service containers to run with `runner-job`
17 | services:
18 | # Label used to access the service container
19 | postgres:
20 | # Docker Hub image
21 | image: postgres
22 | # Provide the password for postgres
23 | env:
24 | POSTGRES_DB: nhanify
25 | POSTGRES_USER: postgres
26 | POSTGRES_PASSWORD: postgres
27 | # Set health checks to wait until postgres has started
28 | options: >-
29 | --health-cmd pg_isready
30 | --health-interval 10s
31 | --health-timeout 5s
32 | --health-retries 5
33 | ports:
34 | # Maps tcp port 5432 on service container to the host
35 | - 5432:5432
36 |
37 | env:
38 | # The hostname used to communicate with the PostgreSQL service container
39 | POSTGRES_HOST: localhost
40 | # The default PostgreSQL port
41 | POSTGRES_PORT: 5432
42 |
43 | # npm
44 | PG_PASSWORD: postgres
45 | PG_USER: postgres
46 | PG_DATABASE: nhanify
47 |
48 | # psql
49 | PGDATABASE: nhanify
50 | PGPASSWORD: postgres
51 |
52 | steps:
53 | # Downloads a copy of the code in your repository before running CI tests
54 | - name: Check out repository code
55 | uses: actions/checkout@v4
56 |
57 | - name: Use Node.js ${{ matrix.node-version }}
58 | uses: actions/setup-node@v4
59 | with:
60 | node-version: ${{ matrix.node-version }}
61 | cache: "npm"
62 | # Performs a clean installation of all dependencies in the `package.json` file
63 | # For more information, see https://docs.npmjs.com/cli/ci.html
64 | - name: Install dependencies
65 | run: npm ci
66 | - name: Use secrets in a script
67 | run: |
68 | echo "YT_API_KEY=${{ secrets.YT_API_KEY }}" >> $GITHUB_ENV
69 | echo "CLIENT_SECRET=${{ secrets.CLIENT_SECRET }}" >> $GITHUB_ENV
70 | echo "CLIENT_ID=${{ secrets.CLIENT_ID }}" >> $GITHUB_ENV
71 | echo "BOT_REFRESH_TOKEN=${{ secrets.BOT_REFRESH_TOKEN }}" >> $GITHUB_ENV
72 | - name: Connect to PostgreSQL (make schema)
73 | # Runs a script that creates a PostgreSQL table, populates
74 | # the table with data, and then retrieves the data
75 | run: psql --host=localhost --username=postgres -v ON_ERROR_STOP=1 < lib/schema.sql
76 | - name: Connect to PostgreSQL (make seed)
77 | run: psql --host=localhost --username=postgres -v ON_ERROR_STOP=1 < lib/seed.sql
78 | # Environment variables used by the `client.js` script to create
79 | # a new PostgreSQL table.
80 | - run: npm run build --if-present
81 | - run: npx db-migrate up --env pg
82 | - run: node Utilities/addSongDuration.js
83 | - run: node Utilities/addTwitchId.js
84 | - run: npm test
85 | - run: npx prettier . --check
86 | - run: npx eslint .
87 |
--------------------------------------------------------------------------------
/public/assets/javascript/iframe.js.map:
--------------------------------------------------------------------------------
1 | {"version":3,"file":"iframe.js","sourceRoot":"","sources":["../../../src/frontend/iframe.ts"],"names":[],"mappings":"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"}
--------------------------------------------------------------------------------
/routes/authRouter.js:
--------------------------------------------------------------------------------
1 | const { CLIENT_SECRET, REDIRECT_URI, CLIENT_ID } = process.env;
2 | const { Router } = require("express");
3 | const authRouter = Router();
4 | const { requireAuth } = require("./middleware.ts");
5 | const catchError = require("./catch-error");
6 | const MSG = require("../lib/msg.json");
7 | // Get the home page.
8 | authRouter.get(
9 | "/",
10 | catchError((req, res) => {
11 | if (req.session.user) {
12 | return res.redirect("/your/playlists/1");
13 | } else {
14 | return res.redirect("/signin");
15 | }
16 | }),
17 | );
18 |
19 | authRouter.get("/twitchAuth", (req, res) => {
20 | res.redirect(
21 | `https://id.twitch.tv/oauth2/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&state=c3ab8aa609ea11e793ae92361f002671&nonce=c3ab8aa609ea11e793ae92361f002671`,
22 | );
23 | });
24 |
25 | authRouter.get("/twitchAuthResponse", async (req, res) => {
26 | const payload = {
27 | client_id: CLIENT_ID,
28 | client_secret: CLIENT_SECRET,
29 | code: req.query.code,
30 | grant_type: "authorization_code",
31 | redirect_uri: REDIRECT_URI,
32 | };
33 | const token = await fetch("https://id.twitch.tv/oauth2/token", {
34 | method: "POST",
35 | headers: { "Content-Type": "application/x-www-form-urlencoded" },
36 | body: new URLSearchParams(payload).toString(),
37 | });
38 | const response = await token.json();
39 | const authUser = await fetch("https://api.twitch.tv/helix/users", {
40 | method: "GET",
41 | headers: {
42 | Authorization: `Bearer ${response.access_token}`,
43 | "Client-Id": CLIENT_ID,
44 | },
45 | });
46 |
47 | const responseAuthUser = await authUser.json();
48 | const persistence = req.app.locals.persistence;
49 | if (responseAuthUser.message === "Invalid OAuth token")
50 | return res.render("signin");
51 | const { id, display_name } = responseAuthUser.data[0];
52 | const user = await persistence.updateUser(display_name, id);
53 | if (!user) {
54 | req.flash("errors", "No account associated with the username. ");
55 | req.session.twitchUsername = display_name;
56 | req.session.twitchId = id;
57 | return res.redirect("/twitchSignup");
58 | }
59 | req.flash("successes", "You are logged in");
60 | req.session.user = user;
61 | return res.redirect("/your/playlists/1");
62 | });
63 |
64 | authRouter.get("/twitchSignup", (req, res) => {
65 | const twitchUsername = req.session.twitchUsername;
66 | if (!twitchUsername) return res.redirect("/signin");
67 | return res.render("twitch_signup", { twitchUsername });
68 | });
69 | authRouter.post("/twitchSignup/create", async (req, res) => {
70 | const persistence = req.app.locals.persistence;
71 | const user = await persistence.createUserTwitch(
72 | req.session.twitchUsername,
73 | req.session.twitchId,
74 | );
75 | req.session.user = user;
76 | req.flash("successes", MSG.createUser);
77 | return res.redirect("/your/playlists/1");
78 | });
79 | authRouter.post("/twitchSignup/cancel", (req, res) => {
80 | delete req.session.twitchUsername;
81 | return res.redirect("/signin");
82 | });
83 |
84 | // Sign out.
85 | authRouter.post(
86 | "/signout",
87 | requireAuth,
88 | catchError((req, res) => {
89 | req.session.destroy((error) => {
90 | if (error) console.error(error);
91 | return res.redirect("/signin");
92 | });
93 | }),
94 | );
95 |
96 | // Get signin form.
97 | authRouter.get(
98 | "/signin",
99 | catchError((req, res) => {
100 | if (req.session.user) {
101 | req.flash("info", MSG.alreadyLoggedIn);
102 | return res.redirect("/your/playlists/1");
103 | }
104 | req.session.originRedirectUrl = req.query.redirectUrl;
105 | return res.render("signin");
106 | }),
107 | );
108 |
109 | module.exports = { authRouter };
110 |
--------------------------------------------------------------------------------
/views/playlists.pug:
--------------------------------------------------------------------------------
1 | extends layout
2 |
3 | block main
4 | .key
5 | if (playlistType === "your")
6 | .userInfo
7 | p.left User Id: #{user.id}
8 | form(action=`/your/playlists/${page}/createApiKey` method="post")
9 | input(type="submit" value="Generate API Key")
10 | if (apiKey)
11 | p.left #{apiKey}
12 | if (playlistTotal === 0)
13 | .errorMessages
14 | - let msgTypes = Object.keys(flash || {})
15 | if (msgTypes.length > 0)
16 | each msgType in msgTypes
17 | each msg in flash[msgType]
18 | pre(class=`${msgType}`) #{msg}
19 | .emptyMessage
20 | p There is currently no playlist.
21 | if (user !== undefined && playlistType === "your")
22 | p Click "Create" to create a playlist.
23 | a(href=`/${playlistType}/playlists/${page}/create`) Create
24 | else
25 | .playlistsDiv
26 | h1(class="pageTitle") #{pageTitle}
27 | block listWrap
28 | block list
29 | .listHeader
30 | - let playlistLabel = playlistTotal < 2 ? "Playlist" : "Playlists"
31 | p(class="username") #{playlistTotal} #{playlistLabel}
32 | if (user !== undefined && playlistType === "your")
33 | a(href=`/${playlistType}/playlists/${page}/create`) Create
34 | if (flash && Object.keys(flash).length > 0)
35 | .errorMessages
36 | if (flash && Object.keys(flash).length > 0)
37 | - let msgTypes = Object.keys(flash || {})
38 | if (msgTypes.length > 0)
39 | each msgType in msgTypes
40 | each msg in flash[msgType]
41 | pre(class=`${msgType}`) #{msg}
42 | .pageNav
43 | if (page > 1)
44 | a(href=`/${playlistType}/playlists/${page - 1}`)
45 | img(src=`/assets/icons/arrow.svg`)
46 | .pages
47 | - for (let pageNum = startPage ; pageNum <= endPage; pageNum++)
48 | a(class=`${(pageNum === page) ? "curPage" : ""}` href=`/${playlistType}/playlists/${pageNum}`) #{pageNum}
49 | if (page < totalPages)
50 | a(href=`/${playlistType}/playlists/${page + 1}`)
51 | img(src=`/assets/icons/arrow.svg` class="rotate180")
52 | .cols
53 | .labelTitle
54 | p(class="username") Title
55 | if (playlistType !== "your")
56 | .labelAddedBy
57 | p(class="username") Creator
58 | .labelSongTotal
59 | p(class="username") Songs
60 | block values
61 | .content
62 | .playListWrap
63 | each playlist in playlists
64 | a(href=`/${playlistType}/playlists/${page}/playlist/1/${playlist.id}`)
65 | div(class="songCard")
66 | .descriptionDiv
67 | .valTitle
68 | p= playlist.title
69 | if (playlistType !== "your")
70 | .valAddedBy
71 | p= playlist.username
72 | .valSongTotal
73 | p= playlist.count
74 | if (user !== undefined)
75 | .modDiv
76 | .val
77 | if (playlistType === "your")
78 | form(action=`/${playlistType}/playlists/${page}/playlist/${playlist.id}/edit` method="get")
79 | input(class="editBtn" type="submit" value="")
80 | .val
81 | if (playlistType === "contribution")
82 | form(class="delete" action=`/${playlistType}/playlists/${page}/playlist/${playlist.id}/delete` method="post")
83 | input(onclick = "confirmSubmit(event, 'Do you want to remove the playlist. You will no longer be contributing to the playlist.')" class="stopContributionBtn" type="submit" value="Stop Contributing")
84 | if (playlistType === "your")
85 | form(class="delete" action=`/${playlistType}/playlists/${page}/playlist/${playlist.id}/delete` method="post")
86 | input(onclick = "confirmSubmit(event,'Do you want to delete the playlist? This will remove the playlist for contributors as well.')" class="deleteBtn" type="submit" value="")
87 |
88 |
--------------------------------------------------------------------------------
/public/assets/javascript/iframe.js:
--------------------------------------------------------------------------------
1 | /* global YT */
2 | // 2. This code loads the IFrame Player API code asynchronously.
3 | export function initalizeIframe() {
4 | console.log("IN INITIALIZE IFRAME");
5 | const tag = document.createElement("script");
6 | tag.src = "https://www.youtube.com/iframe_api";
7 | const firstScriptTag = document.getElementsByTagName("script")[0];
8 | firstScriptTag.parentNode?.insertBefore(tag, firstScriptTag);
9 | let player;
10 | let prevExistingIdx = 0;
11 | const songCards = document.querySelectorAll(".songCard");
12 | let existingVideoIds = [];
13 | if (songCards) existingVideoIds = populatePlaylist(songCards);
14 | // 3. This function creates an