├── 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