{"id":22709546,"url":"https://github.com/mstephen19/kuhlosul-app","last_synced_at":"2026-04-11T05:08:19.011Z","repository":{"id":45476923,"uuid":"431708047","full_name":"mstephen19/kuhlosul-app","owner":"mstephen19","description":"Full-Stack artist portfolio for Kuhlosul utilizing a custom SoundCloud-scraper library written in Node.js, Express, GraphQL, and React.","archived":false,"fork":false,"pushed_at":"2021-12-28T02:55:58.000Z","size":15608,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-02-04T22:14:33.843Z","etag":null,"topics":["apollo-cache","apollo-client","cheerio","context-api","express","graphql","jwt","jwt-authentication","mongodb","mongoose","nodejs","nodemailer","puppeteer","react","react-router","rebass","sessionstorage"],"latest_commit_sha":null,"homepage":"https://kuhlosul-app.herokuapp.com/","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mstephen19.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2021-11-25T03:59:33.000Z","updated_at":"2021-12-12T01:38:26.000Z","dependencies_parsed_at":"2022-07-18T23:32:52.925Z","dependency_job_id":null,"html_url":"https://github.com/mstephen19/kuhlosul-app","commit_stats":null,"previous_names":[],"tags_count":1,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mstephen19%2Fkuhlosul-app","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mstephen19%2Fkuhlosul-app/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mstephen19%2Fkuhlosul-app/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mstephen19%2Fkuhlosul-app/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mstephen19","download_url":"https://codeload.github.com/mstephen19/kuhlosul-app/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":246244416,"owners_count":20746435,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["apollo-cache","apollo-client","cheerio","context-api","express","graphql","jwt","jwt-authentication","mongodb","mongoose","nodejs","nodemailer","puppeteer","react","react-router","rebass","sessionstorage"],"created_at":"2024-12-10T11:10:11.812Z","updated_at":"2025-12-30T20:04:40.073Z","avatar_url":"https://github.com/mstephen19.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Kuhlosul App\n\n[![License: CC BY-NC-ND 4.0](https://img.shields.io/badge/License-CC_BY--NC--ND_4.0-lightgrey.svg)](https://creativecommons.org/licenses/by-nc-nd/4.0/)\n[![GitHub last commit](https://img.shields.io/github/last-commit/mstephen19/kuhlosul-app)]()\n[![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/mstephen19/kuhlosul-app)]()\n\n![Image](./assets/kuhlosulAppDemo.gif)\n\n## Table of Contents\n\n\u003chr\u003e\n\n- [Core Technologies](#core-technologies)\n- [Various Packages Used](#various-packages-used)\n- [Custom ScScraper Library](#scscraper)\n- [Mongoose Models](#models)\n- [updateDb Function](#update-db)\n- [GraphQL Mutations and Queries](#graphql)\n- [JWT](#jwt)\n- [Utilizing React's Context API](#usecontext)\n- [Nodemailer Contact Form](#nodemailer)\n- [Rate-Limiting the API](#rate-limit)\n- [Custom Reusabale Components with Rebass](#custom-components)\n- [Modals with Bootstrap](#modals)\n\u003c!-- - [Extra Security Measures](#extra-security) --\u003e\n- [Credits](#credits)\n- [License](#license)\n\n## About\n\nThe official artist page for my best friend and upcoming DJ/producer [Kuhlosul](https://soundcloud.com/k_dubs). This MERNG PWA includes a standard \"About\" page, a \"Tracks\" page, and a \"Contact\" page. There is also an admin panel which allows admins to complete various tasks such as editing the content on the About page, and updating the database with Kuhlosul's latest tracks (pulled from SoundCloud).\n\n\u003chr\u003e\n\u003ch2\u003e\u003ca href='https://kuhlosul-app.herokuapp.com/'\u003eDeployed Production Build\u003c/a\u003e\u003c/h2\u003e\n\u003chr\u003e\n\n\u003ch2 id='core-technologies'\u003eCore Technologies\u003c/h2\u003e\n\u003chr\u003e\n\n- [React](https://reactjs.org/)\n- [NodeJS](https://nodejs.org/en/)\n- [ExpressJS](https://expressjs.com/)\n- [MongoDB](https://www.mongodb.com/)\n- [GraphQL](https://graphql.org/)\n- [Apollo](https://www.apollographql.com/)\n- [JWT](https://jwt.io/)\n- [Puppeteer](https://pptr.dev/)\n- [Cheerio](https://cheerio.js.org/)\n\n\u003ch2 id='various-packages-used'\u003eVarious Packages Used\u003c/h2\u003e\n\u003chr\u003e\n\n(Not all listed here)\n\n### NodeJS\n\n- [apollo-server-express](https://www.npmjs.com/package/apollo-server-express)\n- [axios](https://www.npmjs.com/package/axios)\n- [bcrypt](https://www.npmjs.com/package/bcrypt)\n- [compression](https://www.npmjs.com/package/compression)\n- [express-rate-limit](https://www.npmjs.com/package/express-rate-limit)\n- [express-slow-down](https://www.npmjs.com/package/express-slow-down)\n- [helmet](https://www.npmjs.com/package/helmet)\n- [mongoose](https://www.npmjs.com/package/mongoose)\n- [nodemailer](https://www.npmjs.com/package/nodemailer)\n\n### React\n\n- [jwt-decode](https://www.npmjs.com/package/jwt-decode)\n- [rebass](https://www.npmjs.com/package/rebass)\n- [react-bootstrap](https://www.npmjs.com/package/react-bootstrap)\n- [react-router-dom](https://www.npmjs.com/package/react-router-dom)\n- [uuidv4](https://www.npmjs.com/package/uuidv4)\n\n\u003ch2 id='scscraper'\u003eScScraper\u003c/h2\u003e\n\u003chr\u003e\n\nThe ScScraper library is a small library specifically created for this application which can scrape data about a SoundCloud user, their tracks, and their tracks which have been posted to different accounts and added to their 'Playlists' page (this is the most popular method of making one's tracks posted elsewhere still show up on their main SoundCloud page).\n\nThe [soundcloud-scraper](https://www.npmjs.com/package/soundcloud-scraper) library is slightly utilized for some of the methods within the ScScraper library; however, the vast majority of the logic is 100% custom.\n\nScScraper's roots lie in two very specific helper functions:\n\n1. **loadProfileBody**\n\nThis function takes in a SoundCloud username and a page on which to run the scrape. It utilized Puppeteer, and returns the HTML of the page based on the parameters specified.\n\n2. **autoScroll**\n\nUsed within the 'loadProfileBody' function, this asynchronous call simply scrolls to the bottom of the page before allowing the loadProfileBody function to return its final value of raw HTML.\n\n\u003chr\u003e\n\nThese two functions are used notoriously within the actual ScScraper class methods. The return value of the loadProfileBody is then parsed and formatted into usable JSON content.\n\n### In action:\n\n```JavaScript\ngetTracks = async function (username, query = 'tracks', headless) {\n  try {\n    const body = await loadProfileBody(username, query, headless);\n\n    const $ = cheerio.load(body);\n\n    const tracks = [];\n\n    $('.sound__body').each((_i, elem) =\u003e {\n      const sound = {\n        title: $(elem).find('.soundTitle__title \u003e span').text().trim(),\n        url:\n          'https://soundcloud.com' +\n          $(elem).find('.soundTitle__title').attr('href'),\n      };\n      tracks.push(sound);\n    });\n    return tracks;\n  } catch (err) {\n    throw new Error('Failed to pull tracks.');\n  }\n};\n```\n\n\u003ch2 id='models'\u003eMongoose Models\u003c/h2\u003e\n\u003chr\u003e\n\nThe small database of this app has three main models:\n\n1. **Admin**\n2. **Track**\n3. **AboutPage**\n\nThey are quite self-explanatory based on name alone. Most of the initial server-side work was done with the 'tracks' MongoDB collection.\n\nThe overall goal is to serve the end-user a list of tracks on the webpage. The problem is that scraping SoundCloud every time the user makes a request is extremely slow and inefficient due to the need to autoScroll each page before scraping it. Because of this, I decided to store the scraped tracks in a MongoDB database.\n\nBecause the data coming from ScScraper was more than necessary to fulfill Kuhlosul's vision of this app, I created a simplistic model for a Track:\n\n```JavaScript\nconst trackSchema = new Schema({\n  title: {\n    type: String,\n  },\n  thumbnail: {\n    type: String,\n  },\n  url: {\n    type: String,\n  },\n  genre: {\n    type: String,\n  },\n  publishedAt: {\n    type: Date,\n  },\n});\n```\n\n\u003ch2 id='update-db'\u003eupdateDb Function\u003c/h2\u003e\n\u003chr\u003e\n\nThe updateDb function is a single asynchronous function which clears the tracks collection within the Db, scrapes SoundCloud for the freshest data, then creates a document within the 'tracks' collection for every single track.\n\nWith this function, we can utilize all of our app's server-side foundation logic in three lines of code:\n\n```JavaScript\nconst runUpdate = async() =\u003e {\n  const added = await updateDb();\n  return added; // Returns a Tracks.find({})\n};\n```\n\nWithin the server.js file, this function is set on a setInterval. It automatically runs in the background every 12 hours to ensure the database is always up-to-date.\n\n\u003ch2 id='graphql'\u003eGraphQL Mutations and Queries\u003c/h2\u003e\n\u003chr\u003e\n\nThis application uses GraphQL entirely. I did create a 'routes' folder when initially creating the project, as I was considering having a hybrid of RESTful routes and GraphQL routes; however, I decided that would become messy and difficult to maintain.\n\nThere are 3 main queries, and 6 main mutations at the moment. This number will increase as updates roll out:\n\n```JavaScript\ntype Query {\n  tracks: [Track]\n  viewdashboard: AdminCheck\n  getAbout: About\n}\n\ntype Mutation {\n  login(email: String!, password: String!): Auth\n  seed: [Track]\n  changePassword(password: String!): Admin\n  createAdmin(email: String!, password: String!): Admin\n  updateAbout(header: String!, body: String!): About\n  sendMessage(\n    email: String!\n    type: String!\n    subject: String!\n    body: String!\n  ): Status\n}\n```\n\nThe 'seed' mutation is the one which is used to manually run the [updateDb function](#update-db) from the admin dashboard:\n\n![Image](./assets/adminFunctions.png)\n\n\u003ch2 id='jwt'\u003eJWT\u003c/h2\u003e\n\u003chr\u003e\n\n### Server-Side\n\nTwo methods are used within the auth.js file of the server's utils folder in order to handle server-side authentication:\n\n1. **signToken**\n\nThis method takes in an 'Admin' object from our database, creates and signs a new token, and returns an object including the '\\_id', 'email', and the newly created web token. Because there is no ability for users to create accounts (except for admins creating new admin accounts), this method is only used in one place - the 'login' mutation resolver:\n\n```JavaScript\nlogin: async (parent, { email, password }) =\u003e {\n  email = email.toLowerCase()\n  // grab the admin corresponding to the email sent in the request\n  const admin = await Admin.findOne({ email });\n\n  if (!admin) return new Error('No admin with this email found!');\n\n  // verify if the password is correct using custom Mongoose hook utilizing bcrypt\n  const correctPass = await admin.checkPassword(password);\n\n  if (!correctPass) return new AuthenticationError('Incorrect passoword!');\n\n  // create the 'Auth' object by passing our admin object into the signToken function\n  const token = signToken(admin);\n\n  // return the token object and the admin object\n  return { token, admin };\n},\n```\n\nIn order to accommodate for the return of this resolver, it was necessary to create an 'Auth' type within my GraphQL typeDefs:\n\n```JavaScript\ntype Auth {\n  token: ID!\n  admin: Admin\n}\n```\n\nThe token is set up to expire after 24 hours.\n\n2. **authMiddleware**\n\nThe authMiddleware method allows us to receive our token initially signed with signToken from the client. I decided to make it very flexible when writing it, and set it up to where the token can be placed in the request's body, query, or headers. Eventually I placed it in the headers.\n\nAfter being pulled from the request, the JWT is then verified, and the decoded data is added to the request object to be used within our GraphQL context.\n\nAdding the authMiddleware function to the ApolloServer:\n\n```JavaScript\nconst server = new ApolloServer({\n  typeDefs,\n  resolvers,\n  context: authMiddleware,\n});\n```\n\nUsing the context within a resolver:\n\n```JavaScript\nchangePassword: async (parent, { password }, context) =\u003e {\n  // if no admin object within context, return custom error\n  if (!context.admin)\n    return new AuthenticationError('Failed to authenticate Admin');\n\n  // find the admin with the context.admin's _id and change the password\n  const withNewPassword = await Admin.findOneAndUpdate(\n    { _id: context.admin._id },\n    { password: password },\n    { new: true }\n  );\n  return withNewPassword;\n},\n```\n\n### Client-Side\n\nUtilizing JWT decode, I created a custom class containing all of the necessary methods to handle client-side authentication, and exported it to be available to all of the app's components.\n\nThere are 6 total methods on this class:\n\n1. **login**\n\nTakes a token as an argument. Adds the token to sessionStorage, then redirects the user to the dashboard.\n\n2. **logout**\n\nRemoves the token from sessionStorage and redirects the user to the homepage.\n\n3. **getToken**\n\nPulls the token from sessionStorage and returns it. This method is more of a helper method, as it is only used within the class itself, and not within any components.\n\n4. **getProfile**\n\nReturns the decoded return value of the getToken method.\n\n5. **isTokenExpired**\n\nTakes a token as an argument, compares its expiration date to the current date, then returns a boolean based on whether or not it's expired.\n\n6. **loggedIn**\n\nRetrieves the existing token from sessionStorage, and returns a boolean based on whether or not the token exists, and whether or not it is expired (utilizing the isTokenExpired method).\n\nExample the login method in use:\n\n```JavaScript\nimport Auth from '../../utils/auth';\n\nconst handleSubmit = async (e) =\u003e {\n  e.preventDefault();\n  try {\n    // Run our login GraphQL mutation, then run the login method\n    const { data } = await login({\n      variables: { ...formValues },\n    });\n\n    Auth.login(data.login.token);\n\n    setFormValues({\n      email: '',\n      password: '',\n    });\n  } catch (err) {\n    alert('There was an error logging in');\n  }\n};\n```\n\nExample of the loggedIn method in use:\n\n```JavaScript\n// If the user is not logged in based on our checks, this link goes to the /login page\n// Otherwise, it goes to the /dashboard page\n\u003cLink\n  to={Auth.loggedIn() ? '/dashboard' : '/login'}\n  style={{ height: '1.5rem', cursor: 'pointer' }}\n\u003e\n  \u003cp style={{ fontSize: '1.5rem', cursor: 'pointer' }}\u003e\n    Admin Dashboard\n  \u003c/p\u003e\n\u003c/Link\u003e\n```\n\n\u003ch2 id='usecontext'\u003eUtilizing React's Context API\u003c/h2\u003e\n\u003chr\u003e\n\nFor a while, I was debating with myself on whether or not to use Redux, or Context API. Though I have experience with both, I eventually settled on the more modern technology - the useContext hook.\n\nI created separate files for actions, reducers, and the context provider itself. At the moment, there is only one action; however, this will surely change in the future, and I am glad to have set this up early-on in the app's development.\n\n'GlobalProvider.js' exports two main things:\n\n1. **Custom 'useGlobalContext' hook**\n\nThis removes the need to import 'useContext' every time the GlobalContext is needed.\n\n2. **GlobalProvider**\n\nA stateful component which uses our reducer function defined in 'reducers.js'. A context provider is returned with the values being the current state of the useReducer, and the useReducer's dispatch function. Like the first export, this eliminates the need to import 'useReducer', as well as our reducer function, every time we want to mutate the global state.\n\nWe can see our global context in action within the 'Dropdown.js' component:\n\n```JavaScript\nconst { currentPage, dispatch } = useGlobalContext();\n\nconst handleItemClick = ({ target }) =\u003e {\n  if (target.id !== currentPage) window.scrollTo(0, 0);\n  dispatch({\n    type: SET_CURRENT_PAGE,\n    payload: target.id,\n  });\n};\n```\n\n\u003ch2 id='nodemailer'\u003eNodemailer Contact Form\u003c/h2\u003e\n\u003chr\u003e\n\nOne of Kuhlosul's visions for the application was to send the message from the contact form to a different email based on the type of message being sent. He has an email for general inquiries, and a separate one for promo emails. The logic for this functionality was handled entirely on the server-side. The request contains the form's information, which includes the value of the dropdown; therefore, a simple ternary operator was able to handle everything:\n\n```JavaScript\nsendMessage: async (parent, { email, type, subject, body }) =\u003e {\n  try {\n    const to =\n      type === 'Promos' ? process.env.PROMO_EMAIL : process.env.MAIN_EMAIL;\n// ...\n```\n\nThe [nodemailer](https://www.npmjs.com/package/nodemailer) package made it fairly easy to handle the sendMessage request. I created a NoReply [Outlook](https://outlook.com) account, and used its credentials within the Nodemailer transporter. The documentation was wildly helpful in finding out exactly which options needed to be used in order to properly connect to and authenticate on various different types of SMTP servers.\n\n\u003ch2 id='rate-limit'\u003eRate-Limiting the API\u003c/h2\u003e\n\u003chr\u003e\n\n### Client-Side \"Rate-Limiting\"\n\nAfter creating the contact form, I immediately realized that some rate limiting was in order to prevent the potential abuse of the site. On the client-side, specifically for the contact form, I created a class with just two main methods.\n\n1. **saveSentTime**\n\nSaves an item in sessionStorage including the current date and time.\n\n2. **canSend**\n\nCompares the current date to the date saved in sessionStorage. If the difference between the two is greater than or equal to a single day, return true.\n\nThough it's nifty, this client-side-only solution is not very secure, and does not prevent the user from spamming the living daylights out of the server.\n\n### Server-Side Rate and Speed Limiting\n\nUsing [express-rate-limit](https://www.npmjs.com/package/express-rate-limit) and [express-slow-down](https://www.npmjs.com/package/express-slow-down) made it an extremely process to set up rate-limiting for all requests to the server:\n\n```JavaScript\nconst rateLimiter = new RateLimit({\n  // Store rateLimit information within the database\n  store: new MongoStore({\n    uri: process.env.MONGODB_URI || 'mongodb://localhost/masondb',\n    // Every 10 minutes, reset back to 0\n    expireTimeMs: 10 * 60 * 1000,\n    errorHandler: console.error.bind(null, 'rate-limit-mongo'),\n    statusCode: 429,\n  }),\n  // Maximum requests === 1000\n  max: 1000,\n  // Every 10 minutes,\n  windowMs: 10 * 60 * 1000,\n});\n\nconst speedLimiter = SpeedLimit({\n  // Every 10 minutes,\n  windowMs: 10 * 60 * 1000,\n  // Begin delaying requests after the 600 cap has been reached within 10 minutes\n  delayAfter: 600,\n  // Delay each request thereafter by 300ms\n  delayMs: 300,\n});\n```\n\n\u003ch2 id='custom-components'\u003eCustom Reusabale Components with Rebass\u003c/h2\u003e\n\u003chr\u003e\n\nThough Rebass is a great library of components as is, I realize that best practice is to create custom components which can later be modified to support a different dependency if needed. Because of this, some of this app's most reused components are custom components built (mostly) with Rebass components.\n\nExample of the 'KInput.js' component:\n\n```JavaScript\nexport default function KInput({\n  id,\n  label,\n  labelColor,\n  type,\n  placeholder,\n  onChange,\n  color = 'white',\n  ...props\n}) {\n  return (\n    \u003cBox style={{ width: '100%', minWidth: '200px', maxWidth: '1000px' }}\u003e\n      \u003cLabel htmlFor={id} style={{ color: labelColor, fontStyle: 'italic' }}\u003e\n        {label}\n      \u003c/Label\u003e\n      \u003cInput\n        id={id}\n        name={type}\n        type={type}\n        placeholder={placeholder}\n        style={{ color: color }}\n        onChange={onChange}\n        {...props}\n      /\u003e\n    \u003c/Box\u003e\n  );\n}\n```\n\n\u003ch2 id='modals'\u003eModals with Bootstrap\u003c/h2\u003e\n\u003chr\u003e\n\nBootstrap modal components within React are among some of the easier ones to work with, and streamlined the creation of the admin dashboard. All functions on the dashboard are done through/confirmed through a Bootstrap modal.\n\n![Image](./assets/adminPanelDemo.gif)\n\n\u003ch2 id='credits'\u003eCredits\u003c/h2\u003e\n\u003chr\u003e\n\n**Matt Stephens**\n\n- [Portfolio](https://mstephen19.github.io/newestPortfolio)\n- [Github](https://github.com/mstephen19)\n- [LinkedIn](https://www.linkedin.com/mstephen19)\n\n\u003ch2 id='license'\u003eLicense\u003c/h2\u003e\n\u003chr\u003e\n\nThis project is protected by a [Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License](https://creativecommons.org/licenses/by-nc-nd/4.0/)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmstephen19%2Fkuhlosul-app","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmstephen19%2Fkuhlosul-app","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmstephen19%2Fkuhlosul-app/lists"}