Initial Commit
--> Linted: CSS No errors were found in the linting process Details
--> Linted: DOCKERFILE_HADOLINT No errors were found in the linting process Details
--> Linted: EDITORCONFIG No errors were found in the linting process Details
--> Linted: GITHUB_ACTIONS No errors were found in the linting process Details
--> Linted: GITLEAKS No errors were found in the linting process Details
--> Linted: JSON No errors were found in the linting process Details
--> Linted: YAML No errors were found in the linting process Details
lint Details
build Details

This commit is contained in:
Chase 2023-07-03 14:05:08 -05:00
commit 5b470e5077
Signed by: chase
GPG Key ID: 9EC29E797878008C
41 changed files with 9534 additions and 0 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
**/.git
**/.node_modules
.env

20
.editorconfig Normal file
View File

@ -0,0 +1,20 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{yml,yaml}]
indent_size = 2
[*.json]
insert_final_newline = unset
[{Makefile,**.mk}]
# Use tabs for indentation (Makefiles require tabs)
indent_style = tab

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
PORT=5000
COOKIE_SECRET=secret
SESSION_SECRET=secret
CSRF_SECRET_MUST_BE_32_CHARS=secretsecretsecretsecretsecret11
BASE_URL=http://localhost:5000
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=

16
.eslintrc.json Normal file
View File

@ -0,0 +1,16 @@
{
"env": {
"es2021": true,
"node": true,
"browser": true
},
"extends": [
"chase"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {},
"globals": {}
}

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

28
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

@ -0,0 +1,28 @@
---
name: Bug Report
about: Report incorrect or unexpected behavior
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
<!--
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files into it.
-->
**To Reproduce**
Steps to reproduce the behavior:
1. Do '...'
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.

2
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1,2 @@
---
blank_issues_enabled: true

View File

@ -0,0 +1,14 @@
---
name: Feature Request
about: Suggest an idea for the project
title: ''
labels: enhancement
assignees: ''
---
**Describe the feature request you'd like**
A clear and concise description of what you want to have added.
**Additional context**
Add any other context or screenshots about the feature request here.

9
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,9 @@
**Please describe the changes this PR makes and why it should be merged:**
<!--
Please move lines that apply to you out of the comment:
- Code changes have been fully tested, or there are no code changes
- This PR includes breaking changes (methods removed or renamed, parameters moved or removed)
- This PR **only** includes non-code changes, like changes to documentation, README, etc.
- I have linted this PR and it passes all tests
-->

15
.github/SECURITY.md vendored Normal file
View File

@ -0,0 +1,15 @@
# Security Policy
## Supported Versions
The only supported version of this project is the latest commit. Nothing else is supported by us.
## Testing
If you believe you have found a vulnerability, be sure you test it in the latest commit.
## Reporting a Vulnerability
If you believe you have discovered a vulnerability or exploit and ensured it happens on the latest commit, please reach out to the project owner's email.
**Do not make a GitHub Issue for a security vulnerability.**

2
.github/linters/.hadolint.yaml vendored Normal file
View File

@ -0,0 +1,2 @@
---
ignored: [DL3018]

5
.github/linters/.htmlhintrc vendored Normal file
View File

@ -0,0 +1,5 @@
{
"title-require": false,
"id-class-value": false,
"head-script-disabled": false
}

6
.github/linters/.stylelintrc.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"rules": {
"selector-class-pattern": null,
"no-missing-end-of-source-newline": null
}
}

4
.github/linters/.yaml-lint.yml vendored Normal file
View File

@ -0,0 +1,4 @@
---
rules:
line-length:
max: 260

28
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,28 @@
---
name: "📦 Publish Docker Container"
on:
push:
branches: [master, main]
env:
PAT: ${{ secrets.PAT }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Build the Docker Image
run: docker build . --file Dockerfile --tag chase/topranks:latest --tag git.chse.dev/chase/topranks:latest
- name: Login to Container Registry
uses: docker/login-action@v2
with:
registry: git.chse.dev
username: chase
password: ${{ secrets.PAT }}
- name: Push the Docker Image to Container Registry
run: docker push git.chse.dev/chase/topranks:latest

39
.github/workflows/linter.yml vendored Normal file
View File

@ -0,0 +1,39 @@
---
name: "🎨 Lint"
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
jobs:
lint:
runs-on: ubuntu-latest
if: github.actor != 'dependabot[bot]' && !contains(github.event.head_commit.message, '[skip lint]')
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: "📦️ Install Dependencies"
run: |
npm install
- name: "🎨 ESLint"
run: npx eslint . --ext .js,.jsx,.ts,.tsx
- name: "🎨 Super-Linter"
uses: github/super-linter/slim@v4
env:
VALIDATE_ALL_CODEBASE: true
DEFAULT_BRANCH: main
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VALIDATE_JAVASCRIPT_ES: false
VALIDATE_JAVASCRIPT_STANDARD: false
VALIDATE_TYPESCRIPT_ES: false
VALIDATE_TYPESCRIPT_STANDARD: false
VALIDATE_JSCPD: false
VALIDATE_MARKDOWN: false
VALIDATE_NATURAL_LANGUAGE: false

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
# Packages
node_modules/
# Private Data
config.json
.env
*.db
*.sqlite*
# Misc
*.bak
.DS_Store

9
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"github.vscode-pull-request-github",
"eamodio.gitlens",
"aaron-bond.better-comments",
"EditorConfig.EditorConfig"
]
}

19
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,19 @@
{
"javascript.format.insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces": true,
"javascript.format.placeOpenBraceOnNewLineForControlBlocks": true,
"javascript.format.placeOpenBraceOnNewLineForFunctions": true,
"editor.formatOnPaste": true,
"editor.formatOnSave": true,
"editor.formatOnType": true,
"editor.detectIndentation": false,
"editor.tabSize": 4,
"files.eol": "\n",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"cSpell.words": [
"deepcode",
"resendverifyemail",
"updateemail"
]
}

6
Dockerfile Normal file
View File

@ -0,0 +1,6 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
CMD [ "node", "." ]

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 chase
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

9
Makefile Normal file
View File

@ -0,0 +1,9 @@
.DEFAULT_GOAL:=help
.PHONY: help
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
update: ## Bumps installed deps.
@npx npm-check-updates -u && npm install

32
README.md Normal file
View File

@ -0,0 +1,32 @@
<div align="center">
<h1><a href="https://top.chse.dev" target="_blank">TopRanks</a><br>
<a href="https://chse.dev/donate"><img alt="Donate" src="https://img.shields.io/badge/Donate_To_This_Project-brightgreen"></a>
</h1></div>
See your top ranking artists/tracks in a unique way.
![img](https://i.chse.dev/p/topranks.png)
## Running
```bash
docker run -d \
--name TopRanks \
-p 3000:3000 \
-e BASE_URL=https://top.chse.dev \
-e COOKIE_SECRET=asdf \
-e SESSION_SECRET=asdf2 \
-e CSRF_SECRET_MUST_BE_32_CHARS=asdf3 \
-e SPOTIFY_CLIENT_ID=asdf4 \
-e SPOTIFY_CLIENT_SECRET=asdf5 \
git.chse.dev/chase/topranks:latest
```
## Development
```bash
git clone https://git.chse.dev/chase/TopRanks.git
cd TopRanks
npm install
cp .env.example .env
# Edit .env to your liking
node .
```

8192
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "topranks",
"version": "1.0.0",
"description": "See your top ranking artists/tracks in a unique way.",
"main": "./src/app.js",
"engines": {
"node": ">=16.14.0"
},
"type": "module",
"scripts": {
"start": "node ./src/app.js",
"lint": "eslint --fix --ext .js,.jsx,.ts,.tsx ./src"
},
"repository": {
"type": "git",
"url": "git+https://github.com/chase/TopRanks.git"
},
"keywords": [],
"author": "Chase <c@chse.dev> (https://chse.dev)",
"contributors": [
""
],
"license": "MIT",
"bugs": {
"url": "https://github.com/chase/TopRanks/issues"
},
"homepage": "https://github.com/chase/TopRanks#readme",
"dependencies": {
"@fortawesome/fontawesome-free": "6.4.0",
"@popperjs/core": "2.11.8",
"bcrypt": "5.1.0",
"better-sqlite3": "8.4.0",
"better-sqlite3-session-store": "0.1.0",
"body-parser": "1.20.2",
"bootstrap": "5.3.0",
"cookie-parser": "1.4.6",
"dotenv": "16.3.1",
"ejs": "3.1.9",
"express": "4.18.2",
"express-flash": "0.0.2",
"express-rate-limit": "6.7.0",
"express-session": "1.17.3",
"helmet": "7.0.0",
"jquery": "3.7.0",
"morgan": "^1.10.0",
"node-fetch": "3.3.1",
"passport": "0.6.0",
"passport-spotify": "2.0.0"
},
"devDependencies": {
"eslint": "8.44.0",
"eslint-config-chase": "1.0.8",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-jsdoc": "46.4.3",
"eslint-plugin-unicorn": "47.0.0"
}
}

3
renovate.json Normal file
View File

@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

131
src/app.js Normal file
View File

@ -0,0 +1,131 @@
/* eslint-disable import/no-extraneous-dependencies */
import express from "express";
import { config } from "dotenv";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import session from "express-session";
import cookieParser from "cookie-parser";
import bodyParser from "body-parser";
import path from "node:path";
import morgan from "morgan";
import flash from "express-flash";
import passport from "passport";
import { Strategy as SpotifyStrategy } from "passport-spotify";
import router from "./router.js";
config(); // Load environment variables from .env file
// file deepcode ignore UseCsurfForExpress: We are using tiny-csrf.
const app = express();
app.set(`trust proxy`, 1); // trust first proxy
const port = process.env.PORT || 3000;
const cookieSecret = process.env.COOKIE_SECRET;
const sessionSecret = process.env.SESSION_SECRET;
const baseUrl = process.env.BASE_URL;
let secureFlag = false;
if (baseUrl.startsWith(`https://`))
secureFlag = true;
// Hide ?code=[...] from logs
morgan.token(`url`, (request) =>
{
if (request.query && request.query.code)
return request.originalUrl.replace(`code=${ request.query.code }`, `code=[...]`);
return request.originalUrl || request.url;
});
app.use(morgan(secureFlag ? `combined` : `dev`));
app.use(bodyParser.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser(cookieSecret));
app.use(session({
secret: sessionSecret,
resave: false,
saveUninitialized: false,
// file deepcode ignore WebCookieSecureDisabledExplicitly: We disable secure if baseUrl does not start with https.
cookie: {
httpOnly: true,
secure: secureFlag,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7d
},
}));
app.use(flash());
app.use(passport.initialize());
app.use(passport.session());
app.use(express.static(`./src/public`)); // Serve static files from public on root (projectRoot/src/public/js -> example.com/js)
// hotload jquery, bootstrap
const projectRoot = path.resolve(`.`);
app.use(`/css`, express.static(path.join(projectRoot, `node_modules/bootstrap/dist/css`)));
app.use(`/js`, express.static(path.join(projectRoot, `node_modules/bootstrap/dist/js`)));
app.use(`/js`, express.static(path.join(projectRoot, `node_modules/jquery/dist`)));
app.use(`/css`, express.static(path.join(projectRoot, `node_modules/@fortawesome/fontawesome-free/css`)));
app.use(`/webfonts`, express.static(path.join(projectRoot, `node_modules/@fortawesome/fontawesome-free/webfonts`)));
app.use(helmet({
contentSecurityPolicy: {
directives: {
...helmet.contentSecurityPolicy.getDefaultDirectives(),
"img-src": [`'self'`, `data:`, `chart.googleapis.com`],
"script-src": [`'self'`, `https://*.hcaptcha.com`],
"default-src": [`'self'`, `https://*.hcaptcha.com`],
},
},
})); // Apply helmet to all requests
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per `window` (here, per 15 minutes)
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
});
app.use(limiter); // Apply rate limiter to all requests
/**
* @name initializePassport
* @description Initialize passport
* @param {*} passport Passport.js Object
*/
function initializePassport(passport)
{
if (process.env.SPOTIFY_CLIENT_ID && process.env.SPOTIFY_CLIENT_SECRET)
{
/* eslint-disable camelcase */
passport.use(new SpotifyStrategy(
{
clientID: process.env.SPOTIFY_CLIENT_ID,
clientSecret: process.env.SPOTIFY_CLIENT_SECRET,
callbackURL: `${ baseUrl }/login/spotify/callback`,
scope: [`user-top-read`],
},
(accessToken, refreshToken, expires_in, profile, done) => done(undefined, accessToken)
));
}
/* eslint-enable camelcase */
passport.serializeUser((user, done) =>
{
done(undefined, user);
});
passport.deserializeUser((user, done) =>
{
done(undefined, user);
});
}
initializePassport(
passport,
(email) => email,
);
app.set(`views`, `./src/views`);
app.set(`view engine`, `ejs`);
app.use(router); // Apply router from src/router.js
app.listen(port, () =>
{
console.log(`Server listening on http://localhost:${ port }!`);
});

41
src/functions.js Normal file
View File

@ -0,0 +1,41 @@
/**
* @name checkAuthenticated
* @description Check if user is authenticated (if auth'd, continue, else redirect to login)
* @param {*} request Express request object
* @param {*} response Express response object
* @param {Function} next Express next function
* @returns {void}
*/
export async function checkAuthenticated(request, response, next)
{
try
{
if (request.isAuthenticated())
return next();
response.redirect(`/`);
}
catch
{
request.logout((error) =>
{
if (error)
return response.status(500).json({ message: `Error logging out` });
return response.redirect(`/`);
});
}
}
/**
* @name checkNotAuthenticated
* @description Check if user is not authenticated (if not auth'd, continue, else redirect to root) [Used to prevent logged in users from accessing login/register pages, etc.]
* @param {*} request Express request object
* @param {*} response Express response object
* @param {Function} next Express next function
* @returns {void}
*/
export function checkNotAuthenticated(request, response, next)
{
if (!request.isAuthenticated())
return next();
return response.redirect(`/profile`); // Already logged in
}

85
src/public/css/common.css Normal file
View File

@ -0,0 +1,85 @@
:root {
/* Dark Mode */
--color-bg: #000000;
--color-fg: #ffffff;
--color-in-focus-from-bg: #383838;
--link-color: #626262;
--link-hover-color: #3f3f3f;
}
@media (prefers-color-scheme: light) {
:root {
/* Light Mode */
--color-bg: #ffffff;
--color-fg: #000000;
--color-in-focus-from-bg: #b2b2b2;
--link-color: #858585;
--link-hover-color: #bcbcbc;
}
}
html {
scroll-behavior: smooth;
}
html,
body,
main,
ul,
ol,
input,
textarea,
select {
background-color: var(--color-bg);
color: var(--color-fg);
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
color: var(--color-fg);
}
body {
margin: auto;
padding: 0px;
display: flex;
min-height: 100vh;
min-width: 100vw;
flex-direction: column;
text-rendering: optimizeLegibility;
}
main#main {
flex: 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
select,
table {
text-align: center;
text-align-last: center;
-moz-text-align-last: center;
margin-left: auto;
margin-right: auto;
}
a {
color: var(--link-color);
display: inline-block;
text-decoration: underline;
font-weight: 400;
}
a:hover,
a:focus,
a:active {
color: var(--link-hover-color);
}

45
src/public/css/home.css Normal file
View File

@ -0,0 +1,45 @@
@import url('./common.css');
html,
body {
text-transform: lowercase;
}
h1 {
padding-top: 10px;
text-transform: none;
}
p {
margin: 1%;
}
#socialSignOnIcons {
margin: 0 auto;
max-width: 40%;
}
.signInButton {
display: inline-block;
color: #fff;
font-family: Arial, sans-serif;
font-size: 14px;
padding: 10px 20px;
margin: 10px 0;
border-radius: 4px;
text-decoration: none;
}
.signInButton .fa {
margin-right: 10px;
}
.signInButton:hover,
.signInButton:focus,
.signInButton:active {
color: #fff;
}
.spotify-button {
background-color: #1DB954;
}

View File

@ -0,0 +1,51 @@
@import url('https://fonts.googleapis.com/css2?family=Fredoka&display=swap');
svg {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -1;
}
main {
margin-top: 1%;
}
button {
margin-top: 5px;
margin-bottom: 5px;
}
#buttons,
p {
text-align: center;
justify-content: center;
}
#mainList {
font-family: 'Fredoka', sans-serif;
}
p {
color: white;
font-size: larger;
font-weight: bolder;
text-shadow: 2px 2px 4px #000000;
}
p#ad {
margin-top: unset;
}
ol,
li,
p {
list-style-position: inside;
text-align: center;
justify-content: center;
padding-left: 0;
color: white;
text-shadow: 2px 2px 4px #000000;
}

BIN
src/public/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

67
src/public/js/common.js Normal file
View File

@ -0,0 +1,67 @@
/* eslint-disable no-undef */
/* eslint-disable no-unused-vars */
const warningTitleCSS = `color:red; font-size:60px; font-weight: bold; -webkit-text-stroke: 1px black;`;
const warningDescCSS = `font-size: 18px;`;
for (let index = 0; index < 3; index++)
{
console.log(`%cStop!`, warningTitleCSS);
console.log(`%cThis is a browser feature intended for developers.`, warningDescCSS);
console.log(`%cIf someone told you to copy and paste something here to enable a feature or "hack" someone's account, it is a scam and will give them access to your account.`, warningDescCSS);
console.log(`%cUnless you understand exactly what you are doing, close this window and stay safe.`, warningDescCSS);
console.log(`%cSee https://en.wikipedia.org/wiki/Self-XSS for more information.`, warningDescCSS);
console.log(`%c `, warningTitleCSS);
}
// User has JS enabled, so let's show them the page.
document.querySelector(`#main`).style.display = `block`;
// onclick of logout-link ID, execute logout();
if (document.querySelector(`#logout-link`))
document.querySelector(`#logout-link`).addEventListener(`click`, logout);
/**
* @name logout
* @description Logs the user out
*/
async function logout()
{
const xhr = new XMLHttpRequest();
xhr.open(`POST`, `/logout`, true);
xhr.setRequestHeader(`Content-Type`, `application/json`);
xhr.send(JSON.stringify({
logout: true,
}));
window.location.reload();
}
/**
* @name darkMode
* @description Sets the dark mode live while user is switching their system settings.
* @param {string} dark if user is using dark
*/
function darkMode(dark)
{
if (dark === `dark`)
{ // Dark mode
const hCaptcha = document.querySelector(`.h-captcha`);
if (hCaptcha)
hCaptcha.setAttribute(`data-theme`, `dark`);
document.querySelector(`html`).setAttribute(`data-bs-theme`, `dark`);
}
else
{ // Light mode
const hCaptcha = document.querySelector(`.h-captcha`);
if (hCaptcha)
hCaptcha.setAttribute(`data-theme`, `light`);
document.querySelector(`html`).setAttribute(`data-bs-theme`, `light`);
}
}
window.matchMedia(`(prefers-color-scheme: dark)`).addEventListener(`change`, (event) =>
{
const colorScheme = event.matches ? `dark` : `light`;
darkMode(colorScheme);
});
darkMode(window.matchMedia(`(prefers-color-scheme: dark)`).matches ? `dark` : `light`);

117
src/public/js/profile.js Normal file
View File

@ -0,0 +1,117 @@
const timeRangeButtons = document.querySelector(`#timeRangeButtons`);
const dataTypeButtons = document.querySelector(`#dataTypeButtons`);
const artistsDataAllTimeNames = document.querySelector(`#artistsDataAllTimeNames`);
const artistsDataSixMonthsNames = document.querySelector(`#artistsDataSixMonthsNames`);
const artistsDataFourWeeksNames = document.querySelector(`#artistsDataFourWeeksNames`);
const trackDataAllTimeNames = document.querySelector(`#trackDataAllTimeNames`);
const trackDataSixMonthsNames = document.querySelector(`#trackDataSixMonthsNames`);
const trackDataFourWeeksNames = document.querySelector(`#trackDataFourWeeksNames`);
timeRangeButtons.addEventListener(`click`, (event) =>
{
const clickedButton = event.target;
const activeTimeRangeButton = timeRangeButtons.querySelector(`.btn-primary`);
activeTimeRangeButton.classList.remove(`btn-primary`);
activeTimeRangeButton.classList.add(`btn-secondary`);
clickedButton.classList.add(`btn-primary`);
clickedButton.classList.remove(`btn-secondary`);
const selectedDataTypeButton = dataTypeButtons.querySelector(`.btn-primary`);
const selectedDataType = selectedDataTypeButton.id;
const selectedTimeRange = clickedButton.id;
handleHiding(selectedTimeRange, selectedDataType);
});
dataTypeButtons.addEventListener(`click`, (event) =>
{
const clickedButton = event.target;
const activeDataTypeButton = dataTypeButtons.querySelector(`.btn-primary`);
activeDataTypeButton.classList.remove(`btn-primary`);
activeDataTypeButton.classList.add(`btn-secondary`);
clickedButton.classList.add(`btn-primary`);
clickedButton.classList.remove(`btn-secondary`);
const selectedTimeRangeButton = timeRangeButtons.querySelector(`.btn-primary`);
const selectedTimeRange = selectedTimeRangeButton.id;
const selectedDataType = clickedButton.id;
handleHiding(selectedTimeRange, selectedDataType);
});
/**
* @param {string} timeRange The time range
* @param {string} dataType The data type
*/
function handleHiding(timeRange, dataType)
{
if (timeRange === `fourWeeks`
&& dataType === `artists`)
{
artistsDataAllTimeNames.classList.add(`d-none`);
artistsDataSixMonthsNames.classList.add(`d-none`);
artistsDataFourWeeksNames.classList.remove(`d-none`);
trackDataAllTimeNames.classList.add(`d-none`);
trackDataSixMonthsNames.classList.add(`d-none`);
trackDataFourWeeksNames.classList.add(`d-none`);
}
else if (timeRange === `sixMonths`
&& dataType === `artists`)
{
artistsDataAllTimeNames.classList.add(`d-none`);
artistsDataSixMonthsNames.classList.remove(`d-none`);
artistsDataFourWeeksNames.classList.add(`d-none`);
trackDataAllTimeNames.classList.add(`d-none`);
trackDataSixMonthsNames.classList.add(`d-none`);
trackDataFourWeeksNames.classList.add(`d-none`);
}
else if (timeRange === `allTime`
&& dataType === `artists`)
{
artistsDataAllTimeNames.classList.remove(`d-none`);
artistsDataSixMonthsNames.classList.add(`d-none`);
artistsDataFourWeeksNames.classList.add(`d-none`);
trackDataAllTimeNames.classList.add(`d-none`);
trackDataSixMonthsNames.classList.add(`d-none`);
trackDataFourWeeksNames.classList.add(`d-none`);
}
else if (timeRange === `fourWeeks`
&& dataType === `tracks`)
{
artistsDataAllTimeNames.classList.add(`d-none`);
artistsDataSixMonthsNames.classList.add(`d-none`);
artistsDataFourWeeksNames.classList.add(`d-none`);
trackDataAllTimeNames.classList.add(`d-none`);
trackDataSixMonthsNames.classList.add(`d-none`);
trackDataFourWeeksNames.classList.remove(`d-none`);
}
else if (timeRange === `sixMonths`
&& dataType === `tracks`)
{
artistsDataAllTimeNames.classList.add(`d-none`);
artistsDataSixMonthsNames.classList.add(`d-none`);
artistsDataFourWeeksNames.classList.add(`d-none`);
trackDataAllTimeNames.classList.add(`d-none`);
trackDataSixMonthsNames.classList.remove(`d-none`);
trackDataFourWeeksNames.classList.add(`d-none`);
}
else if (timeRange === `allTime`
&& dataType === `tracks`)
{
artistsDataAllTimeNames.classList.add(`d-none`);
artistsDataSixMonthsNames.classList.add(`d-none`);
artistsDataFourWeeksNames.classList.add(`d-none`);
trackDataAllTimeNames.classList.remove(`d-none`);
trackDataSixMonthsNames.classList.add(`d-none`);
trackDataFourWeeksNames.classList.add(`d-none`);
}
}
const screenshotModeButton = document.querySelector(`#screenshotModeButton`);
screenshotModeButton.addEventListener(`click`, () =>
{
const buttons = document.querySelectorAll(`.btn`);
for (const button of buttons)
button.style = `visibility: hidden;`;
});

41
src/router.js Normal file
View File

@ -0,0 +1,41 @@
/* eslint-disable import/no-extraneous-dependencies */
import express from "express";
import { config } from "dotenv";
import passport from "passport";
import index from "./routes/index.js";
import notFound from "./routes/404.js";
import logout from "./routes/logout.js";
import profile from "./routes/profile.js";
config(); // Load environment variables from .env file
const router = new express.Router();
router.use(`/`, index);
router.use(`/logout`, logout);
router.use(`/profile`, profile);
router.get(`/error`, (request, response) => response.send(`Error getting data. We may be rate limited by Spotify (try again later).`));
// Social Logins
if (process.env.SPOTIFY_CLIENT_ID && process.env.SPOTIFY_CLIENT_SECRET)
{
router.get(`/login/spotify`, passport.authenticate(`spotify`, {
scope: [`user-top-read`],
}));
router.get(
`/login/spotify/callback`,
passport.authenticate(`spotify`, {
failureRedirect: `/`,
}),
(request, response) =>
{
// take the callback code from the url and redirect to /profile?code=...
response.redirect(`/profile`);
}
);
}
router.use(`/*`, notFound);
export default router;

10
src/routes/404.js Normal file
View File

@ -0,0 +1,10 @@
import express from "express";
const router = new express.Router();
router.get(`/`, (request, response) =>
{
response.redirect(`/`);
});
export default router;

13
src/routes/index.js Normal file
View File

@ -0,0 +1,13 @@
import express from "express";
const router = new express.Router();
// file deepcode ignore NoRateLimitingForExpensiveWebOperation: Already rate limited in src/app.js
router.get(`/`, (request, response) =>
{
response.render(`index.ejs`, {
loggedIn: request.isAuthenticated(),
});
});
export default router;

15
src/routes/logout.js Normal file
View File

@ -0,0 +1,15 @@
import express from "express";
const router = new express.Router();
router.post(`/`, (request, response) =>
{
request.logout((error) =>
{
if (error)
return response.status(500).json({ message: `Error logging out` });
return response.redirect(`/`);
});
});
export default router;

139
src/routes/profile.js Normal file
View File

@ -0,0 +1,139 @@
/* eslint-disable import/no-extraneous-dependencies */
/* eslint-disable newline-per-chained-call */
import express from "express";
import { config } from "dotenv";
import fetch from "node-fetch";
import
{
checkAuthenticated
} from "../functions.js";
const router = new express.Router();
config(); // Load environment variables from .env file
// file deepcode ignore NoRateLimitingForExpensiveWebOperation: Already rate limited in src/app.js
router.get(`/`, checkAuthenticated, async (request, response) =>
{
if (request.user)
{
const code = request.user;
let artistsDataAllTimeNames; let artistsDataSixMonthsNames; let artistsDataFourWeeksNames; let trackDataAllTimeNames;
let trackDataSixMonthsNames; let trackDataFourWeeksNames;
if (process.env.DEV === `true`)
{
// Spotify API please save my wrath.
artistsDataAllTimeNames = [
`Artist Name`,
`Artist Name`,
`Artist Name`,
`Artist Name`,
`Artist Name`,
`Artist Name`,
`Artist Name`,
`Artist Name`,
`Artist Name`,
`Artist Name`,
];
trackDataFourWeeksNames = [
`Song Name - Artist Name`,
`Song Name - Artist Name`,
`Song Name - Artist Name`,
`Song Name - Artist Name`,
`Song Name - Artist Name`,
`Song Name - Artist Name`,
`Song Name - Artist Name`,
`Song Name - Artist Name`,
`Song Name - Artist Name`,
`Song Name - Artist Name`,
];
artistsDataSixMonthsNames = artistsDataAllTimeNames;
artistsDataFourWeeksNames = artistsDataAllTimeNames;
trackDataSixMonthsNames = trackDataFourWeeksNames;
trackDataAllTimeNames = trackDataFourWeeksNames;
}
else
{
const artistsDataAllTime = await fetch(`https://api.spotify.com/v1/me/top/artists?time_range=long_term&limit=10`, {
method: `GET`,
headers: {
Authorization: `Bearer ${ code }`,
},
}).then((response) => response.json());
if (artistsDataAllTime.error)
return response.redirect(`/error`);
artistsDataAllTimeNames = artistsDataAllTime.items.map((item) => item.name);
const artistsDataSixMonths = await fetch(`https://api.spotify.com/v1/me/top/artists?time_range=medium_term&limit=10`, {
method: `GET`,
headers: {
Authorization: `Bearer ${ code }`,
},
}).then((response) => response.json());
artistsDataSixMonthsNames = artistsDataSixMonths.items.map((item) => item.name);
const artistsDataFourWeeks = await fetch(`https://api.spotify.com/v1/me/top/artists?time_range=short_term&limit=10`, {
method: `GET`,
headers: {
Authorization: `Bearer ${ code }`,
},
}).then((response) => response.json());
artistsDataFourWeeksNames = artistsDataFourWeeks.items.map((item) => item.name);
const trackDataAllTime = await fetch(`https://api.spotify.com/v1/me/top/tracks?time_range=long_term&limit=10`, {
method: `GET`,
headers: {
Authorization: `Bearer ${ code }`,
},
}).then((response) => response.json());
trackDataAllTimeNames = trackDataAllTime.items.map((item) =>
{
const artists = item.artists.map((artist) => artist.name);
return `${ item.name } - ${ artists.join(`, `) }`;
});
const trackDataSixMonths = await fetch(`https://api.spotify.com/v1/me/top/tracks?time_range=medium_term&limit=10`, {
method: `GET`,
headers: {
Authorization: `Bearer ${ code }`,
},
}).then((response) => response.json());
trackDataSixMonthsNames = trackDataSixMonths.items.map((item) =>
{
const artists = item.artists.map((artist) => artist.name);
return `${ item.name } - ${ artists.join(`, `) }`;
});
const trackDataFourWeeks = await fetch(`https://api.spotify.com/v1/me/top/tracks?time_range=short_term&limit=10`, {
method: `GET`,
headers: {
Authorization: `Bearer ${ code }`,
},
}).then((response) => response.json());
trackDataFourWeeksNames = trackDataFourWeeks.items.map((item) =>
{
const artists = item.artists.map((artist) => artist.name);
return `${ item.name } - ${ artists.join(`, `) }`;
});
}
response.render(`profile.ejs`, {
artistsDataAllTimeNames,
artistsDataSixMonthsNames,
artistsDataFourWeeksNames,
trackDataAllTimeNames,
trackDataSixMonthsNames,
trackDataFourWeeksNames,
});
}
else
return response.redirect(`/login/spotify`);
});
export default router;

57
src/views/index.ejs Normal file
View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en" prefix="og: https://ogp.me/ns#">
<head>
<title>TopRanks</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="See your top ranking artists/tracks in a unique way." />
<meta name="theme-color" content="#000000">
<meta property="og:title" content="TopRanks" />
<meta property="og:description" content="See your top ranking artists/tracks in a unique way." />
<meta property="og:image" content="/img/logo.png" />
<meta property="og:type" content="website" />
<script src="../js/jquery.min.js"></script>
<link rel="stylesheet" href="../css/bootstrap.min.css">
<script src="../js/bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="../css/fontawesome.min.css">
<link rel="stylesheet" href="../css/brands.min.css">
<link rel="stylesheet" href="/css/home.css">
<link rel="icon" type="image/png" href="/img/logo.png">
</head>
<body>
<noscript>
JavaScript is required to use this app.
</noscript>
<main id="main" style="display: none;">
<h1>TopRanks</h1>
<a href="/login/spotify" class="spotify-button signInButton">
<i class="fab fa-spotify"></i>
Sign In With Spotify
</a>
<hr>
<h2>FAQ</h2>
<h3>My Spotify most-played doesn't look right! How are these stats determined??</h3>
<p>The short answer is, I don't know! <a
href="https://developer.spotify.com/documentation/web-api/reference/get-users-top-artists-and-tracks"
target="_blank">I use data directly collected by Spotify.</a>
Since this data is updated by Spotify, I'm not sure how these stats are calculated.</p>
<h3>Can you show the number of times a track is played?</h3>
<p>Unfortunately, Spotify doesn't provide this data.</p>
<h3>It's not working!</h3>
<p>Sometimes, We might run into errors when there is increased traffic. Please try again in a
few minutes.</p>
<h3>Will you log my data?</h3>
<p>I do not log any personal information. If you want to share your music taste
with me, <a href="mailto:toptracks@chse.dev" target="_blank">shoot me an email</a>.</p>
</main>
<script src="/js/common.js"></script>
</body>
</html>

163
src/views/profile.ejs Normal file
View File

@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en" prefix="og: https://ogp.me/ns#">
<head>
<title>TopRanks</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="See your top ranking artists/tracks in a unique way." />
<meta name="theme-color" content="#000000">
<meta property="og:title" content="TopRanks" />
<meta property="og:description" content="See your top ranking artists/tracks in a unique way." />
<meta property="og:image" content="/img/logo.png" />
<meta property="og:type" content="website" />
<script src="../js/jquery.min.js"></script>
<link rel="stylesheet" href="../css/bootstrap.min.css">
<script src="../js/bootstrap.bundle.min.js"></script>
<!-- <link rel="stylesheet" href="/css/common.css"> -->
<link rel="stylesheet" href="/css/profile.css">
<link rel="icon" type="image/png" href="/img/logo.png">
</head>
<body>
<noscript>
JavaScript is required to use this app.
</noscript>
<svg preserveAspectRatio="xMidYMid slice" viewBox="10 10 80 80">
<defs>
<style>
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.out-top {
animation: rotate 20s linear infinite;
transform-origin: 13px 25px;
}
.in-top {
animation: rotate 10s linear infinite;
transform-origin: 13px 25px;
}
.out-bottom {
animation: rotate 25s linear infinite;
transform-origin: 84px 93px;
}
.in-bottom {
animation: rotate 15s linear infinite;
transform-origin: 84px 93px;
}
</style>
</defs>
<path fill="#9b5de5" class="out-top"
d="M37-5C25.1-14.7,5.7-19.1-9.2-10-28.5,1.8-32.7,31.1-19.8,49c15.5,21.5,52.6,22,67.2,2.3C59.4,35,53.7,8.5,37-5Z" />
<path fill="#f15bb5" class="in-top"
d="M20.6,4.1C11.6,1.5-1.9,2.5-8,11.2-16.3,23.1-8.2,45.6,7.4,50S42.1,38.9,41,24.5C40.2,14.1,29.4,6.6,20.6,4.1Z" />
<path fill="#00bbf9" class="out-bottom"
d="M105.9,48.6c-12.4-8.2-29.3-4.8-39.4.8-23.4,12.8-37.7,51.9-19.1,74.1s63.9,15.3,76-5.6c7.6-13.3,1.8-31.1-2.3-43.8C117.6,63.3,114.7,54.3,105.9,48.6Z" />
<path fill="#00f5d4" class="in-bottom"
d="M102,67.1c-9.6-6.1-22-3.1-29.5,2-15.4,10.7-19.6,37.5-7.6,47.8s35.9,3.9,44.5-12.5C115.5,92.6,113.9,74.6,102,67.1Z" />
</svg>
<main id="main" style="display: none;">
<div id="buttons">
<a href="#" id="logout-link" class="btn btn-secondary">Logout</a>
<!-- buttons to select time range -->
<div id="timeRangeButtons">
<button id="fourWeeks" class="timeRangeButton btn btn-primary">Past Month</button>
<button id="sixMonths" class="timeRangeButton btn btn-secondary">Six Months</button>
<button id="allTime" class="timeRangeButton btn btn-secondary">All Time</button>
</div>
<!-- buttons to select data type -->
<div id="dataTypeButtons">
<button id="artists" class="dataTypeButton btn btn-primary">Artists</button>
<button id="tracks" class="dataTypeButton btn btn-secondary">Tracks</button>
</div>
<button id="screenshotModeButton" class="btn btn-secondary">Screenshot Mode</button>
</div>
<div id="mainList">
<div id="artistsDataAllTimeNames" class="cd d-none">
<p>Your top artists of all time</p>
<ol id="artistsDataAllTimeNamesList">
<% artistsDataAllTimeNames.forEach(function(artist) { %>
<li>
<%= artist %>
</li>
<% }); %>
</ol>
<p id="ad">Presented by top.chse.dev</p>
</div>
<div id="artistsDataSixMonthsNames" class="cd d-none">
<p>Your top artists of the last six months</p>
<ol id="artistsDataSixMonthsNamesList">
<% artistsDataSixMonthsNames.forEach(function(artist) { %>
<li>
<%= artist %>
</li>
<% }); %>
</ol>
<p id="ad">Presented by top.chse.dev</p>
</div>
<div id="artistsDataFourWeeksNames" class="cd">
<p>Your top artists of the month</p>
<ol id="artistsDataFourWeeksNamesList">
<% artistsDataFourWeeksNames.forEach(function(artist) { %>
<li>
<%= artist %>
</li>
<% }); %>
</ol>
<p id="ad">Presented by top.chse.dev</p>
</div>
<div id="trackDataAllTimeNames" class="cd d-none">
<p>Your top tracks of all time</p>
<ol id="trackDataAllTimeNamesList">
<% trackDataAllTimeNames.forEach(function(track) { %>
<li>
<%= track %>
</li>
<% }); %>
</ol>
<p id="ad">Presented by top.chse.dev</p>
</div>
<div id="trackDataSixMonthsNames" class="cd d-none">
<p>Your top tracks of the last six months</p>
<ol id="trackDataSixMonthsNamesList">
<% trackDataSixMonthsNames.forEach(function(track) { %>
<li>
<%= track %>
</li>
<% }); %>
</ol>
<p id="ad">Presented by top.chse.dev</p>
</div>
<div id="trackDataFourWeeksNames" class="cd d-none">
<p>Your top tracks of the month</p>
<ol id="trackDataFourWeeksNamesList">
<% trackDataFourWeeksNames.forEach(function(track) { %>
<li>
<%= track %>
</li>
<% }); %>
</ol>
<p id="ad">Presented by top.chse.dev</p>
</div>
</div>
</main>
<script src="/js/common.js"></script>
<script src="/js/profile.js"></script>
</body>
</html>