├── .github ├── CODEOWNERS ├── FUNDING.yml ├── labeler.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── stale.yml ├── workflows │ ├── docs.yml │ ├── server.yml │ └── client.yml ├── CONTRIBUTING.md └── PULL_REQUEST_TEMPLATE.md ├── docs ├── content │ ├── endpoints │ │ ├── dump.md │ │ ├── vote.md │ │ ├── README.md │ │ ├── auth.md │ │ ├── download.md │ │ ├── stats.md │ │ ├── users.md │ │ ├── search.md │ │ └── maps.md │ ├── usage │ │ ├── errors.md │ │ ├── rate-limits.md │ │ ├── semantics.md │ │ └── README.md │ ├── responses │ │ ├── README.md │ │ ├── pagination.md │ │ ├── user.md │ │ └── beatmap.md │ ├── .vuepress │ │ ├── public │ │ │ └── favicon.png │ │ ├── styles │ │ │ └── index.styl │ │ └── config.js │ └── README.md ├── .eslintignore ├── .dockerignore ├── .eslintrc.js ├── Dockerfile ├── nginx.conf └── package.json ├── client ├── .sassrc ├── src │ ├── ts │ │ ├── utils │ │ │ ├── fontAwesome.ts │ │ │ ├── env.ts │ │ │ ├── dom.ts │ │ │ ├── swal.ts │ │ │ ├── sessionStore.ts │ │ │ ├── characteristics.ts │ │ │ ├── axios.ts │ │ │ ├── formatDate.ts │ │ │ └── scroll.ts │ │ ├── constants.ts │ │ ├── components │ │ │ ├── Beatmap │ │ │ │ ├── index.tsx │ │ │ │ ├── BeatmapList.tsx │ │ │ │ ├── DiffTags.tsx │ │ │ │ ├── Statistic.tsx │ │ │ │ ├── BeatmapScroller.tsx │ │ │ │ ├── BeatmapAPI.tsx │ │ │ │ └── Statistics.tsx │ │ │ ├── Navbar │ │ │ │ ├── NavbarDivider.tsx │ │ │ │ ├── NavbarDropdown.tsx │ │ │ │ └── NavbarItem.tsx │ │ │ ├── Loader.tsx │ │ │ ├── ExtLink.tsx │ │ │ ├── APIError.tsx │ │ │ ├── TextPage.tsx │ │ │ ├── Footer.tsx │ │ │ ├── Image.tsx │ │ │ ├── Boundary.tsx │ │ │ ├── FileInput.tsx │ │ │ ├── LegalPage.tsx │ │ │ └── Input.tsx │ │ ├── remote │ │ │ ├── user.d.ts │ │ │ ├── response.d.ts │ │ │ ├── download.ts │ │ │ ├── beatmap.d.ts │ │ │ └── request.ts │ │ ├── store │ │ │ ├── audio │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ ├── reducer.ts │ │ │ │ └── actions.ts │ │ │ ├── images │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ ├── reducer.ts │ │ │ │ └── actions.ts │ │ │ ├── user │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ ├── reducer.ts │ │ │ │ └── actions.ts │ │ │ ├── scrollers │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ ├── actions.ts │ │ │ │ └── reducer.ts │ │ │ ├── types.d.ts │ │ │ └── index.ts │ │ ├── types │ │ │ ├── images.d.ts │ │ │ └── react-nl2br.d.ts │ │ ├── routes │ │ │ ├── NotFound.tsx │ │ │ ├── Beatmap.tsx │ │ │ ├── Browse.tsx │ │ │ ├── Index.tsx │ │ │ ├── Legacy.tsx │ │ │ ├── License.tsx │ │ │ ├── Search.tsx │ │ │ ├── Uploader.tsx │ │ │ └── Login.tsx │ │ ├── init.ts │ │ ├── App.tsx │ │ └── Routes.tsx │ ├── images │ │ └── beat_saver_logo_white.png │ ├── sass │ │ ├── fontAwesome.scss │ │ ├── _details.scss │ │ ├── _util.scss │ │ ├── global.scss │ │ ├── _tags.scss │ │ ├── _input.scss │ │ ├── _scrollbar.scss │ │ ├── _layout.scss │ │ ├── _navbar.scss │ │ ├── _loader.scss │ │ ├── _bulma.scss │ │ └── _beatmap-result.scss │ ├── index.tsx │ └── index.html ├── .postcssrc ├── .babelrc ├── .prettierrc.json ├── ssr │ ├── signale.ts │ ├── env.ts │ ├── generateHTML.ts │ ├── index.ts │ └── middleware.tsx ├── tsconfig.json ├── nginx.conf ├── tslint.json ├── .dockerignore ├── Dockerfile └── package.json ├── server ├── src │ ├── tasks │ │ ├── index.ts │ │ ├── syncSchemas.ts │ │ └── dumps.ts │ ├── mongo │ │ ├── models │ │ │ ├── index.ts │ │ │ └── User.ts │ │ └── plugins │ │ │ ├── withoutVersionKey.ts │ │ │ ├── index.ts │ │ │ ├── withVirtuals.ts │ │ │ ├── withoutKeys.ts │ │ │ ├── paginate.ts │ │ │ └── mongoosastic.d.ts │ ├── redis │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── await.ts │ │ └── db.ts │ ├── strategies │ │ ├── index.ts │ │ ├── local.ts │ │ └── jwt.ts │ ├── mail │ │ ├── interfaces.ts │ │ ├── utils.ts │ │ ├── index.ts │ │ ├── sendgrid.ts │ │ └── log.ts │ ├── middleware │ │ ├── index.ts │ │ ├── realIP.ts │ │ ├── logger.ts │ │ ├── errors.ts │ │ ├── ratelimit.ts │ │ └── cache.ts │ ├── utils │ │ ├── json.ts │ │ ├── signale.ts │ │ ├── axios.ts │ │ ├── misc.ts │ │ ├── streams.ts │ │ ├── parseKey.ts │ │ ├── CodedError.ts │ │ ├── schemas.ts │ │ └── fs.ts │ ├── routes │ │ ├── cdn.ts │ │ ├── index.ts │ │ ├── legal.ts │ │ ├── users.ts │ │ ├── manage.ts │ │ ├── auth.ts │ │ ├── download.ts │ │ ├── admin.ts │ │ ├── stats.ts │ │ ├── upload │ │ │ ├── parseValidationError.ts │ │ │ ├── types.ts │ │ │ ├── index.ts │ │ │ └── errors.ts │ │ └── vote.ts │ ├── koa.ts │ ├── constants.ts │ ├── index.ts │ └── env.ts ├── .prettierrc.json ├── .dockerignore ├── tslint.json ├── Dockerfile ├── tsconfig.json └── package.json ├── Caddyfile ├── .editorconfig ├── LICENSE ├── .env.example ├── docker-compose.yml └── README.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @lolpants 2 | -------------------------------------------------------------------------------- /docs/content/endpoints/dump.md: -------------------------------------------------------------------------------- 1 | # /dump 2 | -------------------------------------------------------------------------------- /docs/content/endpoints/vote.md: -------------------------------------------------------------------------------- 1 | # /vote 2 | -------------------------------------------------------------------------------- /docs/content/usage/errors.md: -------------------------------------------------------------------------------- 1 | # HTTP Errors 2 | -------------------------------------------------------------------------------- /docs/content/responses/README.md: -------------------------------------------------------------------------------- 1 | # API Responses 2 | -------------------------------------------------------------------------------- /docs/content/responses/pagination.md: -------------------------------------------------------------------------------- 1 | # Pagination 2 | -------------------------------------------------------------------------------- /docs/content/responses/user.md: -------------------------------------------------------------------------------- 1 | # User Response 2 | -------------------------------------------------------------------------------- /docs/content/usage/rate-limits.md: -------------------------------------------------------------------------------- 1 | # Rate Limits 2 | -------------------------------------------------------------------------------- /docs/content/usage/semantics.md: -------------------------------------------------------------------------------- 1 | # HTTP Semantics 2 | -------------------------------------------------------------------------------- /docs/content/responses/beatmap.md: -------------------------------------------------------------------------------- 1 | # Beatmap Response 2 | -------------------------------------------------------------------------------- /client/.sassrc: -------------------------------------------------------------------------------- 1 | { 2 | "includePaths": ["node_modules"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/.eslintignore: -------------------------------------------------------------------------------- 1 | !/content/.vuepress 2 | content/.vuepress/dist 3 | -------------------------------------------------------------------------------- /server/src/tasks/index.ts: -------------------------------------------------------------------------------- 1 | import './dumps' 2 | import './syncSchemas' 3 | -------------------------------------------------------------------------------- /client/src/ts/utils/fontAwesome.ts: -------------------------------------------------------------------------------- 1 | import '../../sass/fontAwesome.scss' 2 | -------------------------------------------------------------------------------- /client/.postcssrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "autoprefixer": {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/src/ts/constants.ts: -------------------------------------------------------------------------------- 1 | export const SEVEN_DAYS = 1000 * 60 * 60 * 24 * 7 2 | -------------------------------------------------------------------------------- /client/src/ts/utils/env.ts: -------------------------------------------------------------------------------- 1 | export const IS_DEV = process.env.NODE_ENV !== 'production' 2 | -------------------------------------------------------------------------------- /server/src/mongo/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Beatmap' 2 | export * from './User' 3 | -------------------------------------------------------------------------------- /server/src/redis/constants.ts: -------------------------------------------------------------------------------- 1 | export const RATE_LIMIT_DB = 0 2 | export const CACHE_DB = 1 3 | -------------------------------------------------------------------------------- /client/src/ts/components/Beatmap/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Detail' 2 | export * from './BeatmapList' 3 | -------------------------------------------------------------------------------- /docs/content/endpoints/README.md: -------------------------------------------------------------------------------- 1 | # Endpoints Reference 2 | Use the sidebar to navigate to an endpoint. 3 | -------------------------------------------------------------------------------- /server/src/redis/index.ts: -------------------------------------------------------------------------------- 1 | export * from './await' 2 | export * from './constants' 3 | export * from './db' 4 | -------------------------------------------------------------------------------- /client/src/ts/remote/user.d.ts: -------------------------------------------------------------------------------- 1 | declare interface IUserResponse { 2 | _id: string 3 | username: string 4 | } 5 | -------------------------------------------------------------------------------- /docs/content/endpoints/auth.md: -------------------------------------------------------------------------------- 1 | # /auth 2 | Handles user authentication, allowing you to upload and edit beatmaps. 3 | -------------------------------------------------------------------------------- /server/src/strategies/index.ts: -------------------------------------------------------------------------------- 1 | import './jwt' 2 | import './local' 3 | 4 | export { issueToken } from './jwt' 5 | -------------------------------------------------------------------------------- /client/src/ts/store/audio/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | export * from './types' 4 | -------------------------------------------------------------------------------- /client/src/ts/store/images/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | export * from './types' 4 | -------------------------------------------------------------------------------- /client/src/ts/store/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | export * from './types' 4 | -------------------------------------------------------------------------------- /client/src/ts/store/scrollers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | export * from './types' 4 | -------------------------------------------------------------------------------- /client/src/ts/types/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' 2 | declare module '*.jpg' 3 | declare module '*.jpeg' 4 | declare module '*.svg' 5 | -------------------------------------------------------------------------------- /server/src/mail/interfaces.ts: -------------------------------------------------------------------------------- 1 | type MailerFunction = ( 2 | to: string, 3 | subject: string, 4 | body: string 5 | ) => Promise 6 | -------------------------------------------------------------------------------- /docs/content/.vuepress/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luludotdev/beatsaver-reloaded/HEAD/docs/content/.vuepress/public/favicon.png -------------------------------------------------------------------------------- /client/src/images/beat_saver_logo_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luludotdev/beatsaver-reloaded/HEAD/client/src/images/beat_saver_logo_white.png -------------------------------------------------------------------------------- /client/src/sass/fontAwesome.scss: -------------------------------------------------------------------------------- 1 | @import "@fortawesome/fontawesome-free/css/solid.css"; 2 | @import "@fortawesome/fontawesome-free/css/fontawesome.css"; 3 | -------------------------------------------------------------------------------- /server/src/mongo/plugins/withoutVersionKey.ts: -------------------------------------------------------------------------------- 1 | import { withoutKeys } from './withoutKeys' 2 | 3 | export const withoutVersionKey = withoutKeys(['__v']) 4 | -------------------------------------------------------------------------------- /server/src/mongo/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './paginate' 2 | export * from './withoutKeys' 3 | export * from './withoutVersionKey' 4 | export * from './withVirtuals' 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ['lolPants'] 2 | patreon: JackBaron 3 | ko_fi: lolpants 4 | custom: 5 | - 'https://www.paypal.me/jackbarondev' 6 | - 'https://monzo.me/jackbaron' 7 | -------------------------------------------------------------------------------- /server/src/middleware/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cache' 2 | export * from './errors' 3 | export * from './logger' 4 | export * from './ratelimit' 5 | export * from './realIP' 6 | -------------------------------------------------------------------------------- /server/src/tasks/syncSchemas.ts: -------------------------------------------------------------------------------- 1 | import { schedule } from 'node-cron' 2 | import * as schemas from '~utils/schemas' 3 | 4 | schedule('0 */1 * * *', async () => schemas.sync()) 5 | -------------------------------------------------------------------------------- /client/src/ts/utils/dom.ts: -------------------------------------------------------------------------------- 1 | export const canUseDom = () => 2 | !!( 3 | typeof window !== 'undefined' && 4 | window.document && 5 | window.document.createElement 6 | ) 7 | -------------------------------------------------------------------------------- /server/src/utils/json.ts: -------------------------------------------------------------------------------- 1 | export const validJSON = (str: string) => { 2 | try { 3 | JSON.parse(str) 4 | return true 5 | } catch (err) { 6 | return false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/ts/types/react-nl2br.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-nl2br' { 2 | import { ReactElement } from 'react' 3 | 4 | function nl2br(str: string): ReactElement 5 | export = nl2br 6 | } 7 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | client: 2 | - client/**/* 3 | 4 | server: 5 | - server/**/* 6 | 7 | docs: 8 | - docs/**/* 9 | 10 | dependencies: 11 | - client/yarn.lock 12 | - server/yarn.lock 13 | -------------------------------------------------------------------------------- /client/src/ts/utils/swal.ts: -------------------------------------------------------------------------------- 1 | import SweetAlert from 'sweetalert2' 2 | import withReactContent from 'sweetalert2-react-content' 3 | 4 | const swal = withReactContent(SweetAlert) 5 | export default swal 6 | -------------------------------------------------------------------------------- /docs/content/usage/README.md: -------------------------------------------------------------------------------- 1 | # Using the API 2 | The BeatSaver API is a standard REST API, with endpoints grouped into routes. 3 | You can see the list of all available endpoints [here](/endpoints/). 4 | -------------------------------------------------------------------------------- /docs/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | 4 | logs 5 | *.log 6 | npm-debug.log* 7 | 8 | .vscode 9 | .gitignore 10 | LICENSE 11 | Dockerfile 12 | .travis.yml 13 | 14 | content/.vuepress/dist 15 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | beatsaver.com { 2 | import common 3 | 4 | proxy / localhost:8000 { 5 | transparent 6 | } 7 | 8 | proxy /api localhost:3000 { 9 | transparent 10 | without /api 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | ["@babel/plugin-transform-runtime", { "regenerator": true }] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /client/src/ts/components/Navbar/NavbarDivider.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | export const NavbarDivider: FunctionComponent = () => ( 4 |
5 | ) 6 | -------------------------------------------------------------------------------- /client/src/ts/store/images/types.ts: -------------------------------------------------------------------------------- 1 | export enum ImagesActionTypes { 2 | SET_IMAGE = '@@images/SET_IMAGE', 3 | RESET_IMAGE = '@@images/RESET_IMAGE', 4 | } 5 | 6 | export interface IImagesState { 7 | [key: string]: boolean | undefined 8 | } 9 | -------------------------------------------------------------------------------- /docs/content/endpoints/download.md: -------------------------------------------------------------------------------- 1 | # /download 2 | Handles downloading beatmaps to your machine. 3 | 4 | ## /download/key/:key 5 | Downloads the Beatmap ZIP using the key. 6 | 7 | ## /download/hash/:hash 8 | Downloads the Beatmap ZIP using the hash. 9 | -------------------------------------------------------------------------------- /server/src/mongo/plugins/withVirtuals.ts: -------------------------------------------------------------------------------- 1 | import { Schema } from 'mongoose' 2 | 3 | export const withVirtuals = (schema: Schema) => { 4 | const toJSON = schema.get('toJSON') 5 | schema.set('toJSON', { ...toJSON, virtuals: true, getters: true }) 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /client/src/sass/_details.scss: -------------------------------------------------------------------------------- 1 | details.details { 2 | margin-bottom: 12px; 3 | 4 | & summary { 5 | font-size: 0.9em; 6 | text-transform: uppercase; 7 | font-weight: bold; 8 | 9 | outline: none; 10 | cursor: pointer; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/ts/utils/sessionStore.ts: -------------------------------------------------------------------------------- 1 | window.onbeforeunload = () => { 2 | sessionStorage.setItem('origin', window.location.href) 3 | } 4 | 5 | window.onload = () => { 6 | if (window.location.href === sessionStorage.getItem('origin')) { 7 | sessionStorage.clear() 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/mail/utils.ts: -------------------------------------------------------------------------------- 1 | import { IUserModel } from '~mongo/models' 2 | 3 | export const formatRecipient = (user: IUserModel) => 4 | `${user.username} <${user.email}>` 5 | 6 | export const sendTo = (user: string | IUserModel) => 7 | typeof user === 'string' ? user : formatRecipient(user) 8 | -------------------------------------------------------------------------------- /server/src/routes/cdn.ts: -------------------------------------------------------------------------------- 1 | import Router from 'koa-router' 2 | import { Serve as serve } from 'static-koa-router' 3 | import { CDN_PATH } from '~constants' 4 | 5 | const router = new Router({ 6 | prefix: '/cdn', 7 | }) 8 | 9 | serve(CDN_PATH, router) 10 | export { router as cdnRouter } 11 | -------------------------------------------------------------------------------- /client/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "endOfLine": "lf" 11 | } 12 | -------------------------------------------------------------------------------- /client/src/ts/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | export const Loader: FunctionComponent = () => ( 4 |
5 |
6 |
7 |
8 |
9 |
10 | ) 11 | -------------------------------------------------------------------------------- /server/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "tabWidth": 2, 7 | "useTabs": false, 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "endOfLine": "lf" 11 | } 12 | -------------------------------------------------------------------------------- /server/src/mail/index.ts: -------------------------------------------------------------------------------- 1 | import { MAIL_DRIVER } from '~environment' 2 | 3 | const mailDriver: () => MailerFunction = () => { 4 | if (MAIL_DRIVER === 'sendgrid') return require('./sendgrid').default 5 | else return require('./log').default 6 | } 7 | 8 | export default mailDriver 9 | export * from './utils' 10 | -------------------------------------------------------------------------------- /client/src/sass/_util.scss: -------------------------------------------------------------------------------- 1 | .mono { 2 | font-family: 'Fira Code', 'Fira Mono', monospace; 3 | } 4 | 5 | .swal-validation-content p:not(:last-child) { 6 | margin-bottom: 14px; 7 | } 8 | 9 | .license { 10 | text-align: justify; 11 | 12 | & p:not(:last-child) { 13 | margin-bottom: 14px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/sass/global.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | @import "_scrollbar"; 3 | @import "_util"; 4 | @import "_bulma"; 5 | @import "_navbar"; 6 | @import "_layout"; 7 | @import "_loader"; 8 | @import "_input"; 9 | @import "_beatmap-result"; 10 | @import "_beatmap-detail"; 11 | @import "_tags"; 12 | @import "_details"; 13 | -------------------------------------------------------------------------------- /client/src/ts/routes/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { TextPage } from '../components/TextPage' 3 | 4 | export const NotFound: FunctionComponent = () => ( 5 | 6 |

This page doesn't exist. Use the navbar to get back on track!

7 |
8 | ) 9 | -------------------------------------------------------------------------------- /docs/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: 'sora/vue', 3 | plugins: ['markdown'], 4 | 5 | env: { 6 | node: true, 7 | }, 8 | 9 | rules: { 10 | semi: 0, 11 | indent: ['error', 2, { 'SwitchCase': 1 }], 12 | 13 | 'vue/html-indent': ['error', 2], 14 | 'vue/html-quotes': ['error', 'single'], 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /docs/content/endpoints/stats.md: -------------------------------------------------------------------------------- 1 | # /stats 2 | 3 | ## /stats/key/:key 4 | Returns the Beatmap ID, hash, and statistics associated with the passed in beatmap key (including downloads, plays, etc.) 5 | 6 | ## /stats/by-hash/:hash 7 | Returns the same as above, but accepts the Beatmap hash instead. 8 | *(Both examples return the same beatmap object)* 9 | -------------------------------------------------------------------------------- /server/src/mail/sendgrid.ts: -------------------------------------------------------------------------------- 1 | import mail from '@sendgrid/mail' 2 | import { MAIL_FROM, SENDGRID_KEY } from '~environment' 3 | 4 | mail.setApiKey(SENDGRID_KEY) 5 | 6 | const smtpDriver: MailerFunction = async (to, subject, body) => { 7 | await mail.send({ from: MAIL_FROM, to, subject, text: body }) 8 | } 9 | 10 | export default smtpDriver 11 | -------------------------------------------------------------------------------- /client/ssr/signale.ts: -------------------------------------------------------------------------------- 1 | import { Signale } from 'signale' 2 | 3 | const signale = new Signale({ 4 | config: { 5 | displayDate: true, 6 | displayTimestamp: true, 7 | }, 8 | }) 9 | 10 | export default signale 11 | 12 | export const panic = (message: string | Error, code: number = 1) => { 13 | signale.fatal(message) 14 | return process.exit(code) 15 | } 16 | -------------------------------------------------------------------------------- /docs/content/endpoints/users.md: -------------------------------------------------------------------------------- 1 | # /users 2 | 3 | ## /users/me 4 | ### Prerequisites: Must be authenticated 5 | Returns information of the currently logged-in user, including the ID, username, their admin status, their verification status, and the links to beatmaps they've created. 6 | 7 | ## /users/find/:id 8 | Returns the information corresponding to the passed in User ID. 9 | -------------------------------------------------------------------------------- /server/src/utils/signale.ts: -------------------------------------------------------------------------------- 1 | import { Signale } from 'signale' 2 | 3 | const signale = new Signale({ 4 | config: { 5 | displayDate: true, 6 | displayTimestamp: true, 7 | }, 8 | }) 9 | 10 | export default signale 11 | 12 | export const panic = (message: string | Error, code: number = 1) => { 13 | signale.fatal(message) 14 | return process.exit(code) 15 | } 16 | -------------------------------------------------------------------------------- /server/src/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import Axios from 'axios' 2 | import readPkg from 'read-pkg-up' 3 | 4 | const pkg = readPkg.sync() 5 | const version = (pkg && pkg.package.version) || 'unknown' 6 | const userAgent = `beatsaver-reloaded/${version}` 7 | 8 | const axios = Axios.create({ 9 | headers: { 10 | 'User-Agent': userAgent, 11 | }, 12 | }) 13 | 14 | export default axios 15 | -------------------------------------------------------------------------------- /client/src/ts/routes/Beatmap.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { RouteComponentProps } from 'react-router-dom' 3 | import { BeatmapDetail } from '../components/Beatmap' 4 | 5 | interface IParams { 6 | key: string 7 | } 8 | 9 | export const Beatmap: FunctionComponent> = ({ 10 | match, 11 | }) => 12 | -------------------------------------------------------------------------------- /client/src/ts/store/user/types.ts: -------------------------------------------------------------------------------- 1 | export enum UserActionTypes { 2 | SET_USER = '@@user/SET_USER', 3 | } 4 | 5 | export interface IUser { 6 | _id: string 7 | username: string 8 | 9 | verified: boolean 10 | admin: boolean 11 | 12 | links: { 13 | steam?: string 14 | oculus?: string 15 | } 16 | } 17 | 18 | export interface IUserState { 19 | login: IUser | null | undefined 20 | } 21 | -------------------------------------------------------------------------------- /server/src/middleware/realIP.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from 'koa' 2 | 3 | declare module 'koa' { 4 | // tslint:disable-next-line: interface-name 5 | interface ContextDelegatedRequest { 6 | realIP: string 7 | } 8 | } 9 | 10 | export const realIP: Middleware = async (ctx, next) => { 11 | ctx.realIP = 12 | ctx.headers['cf-connecting-ip'] || ctx.headers['x-forwarded-for'] || ctx.ip 13 | 14 | return next() 15 | } 16 | -------------------------------------------------------------------------------- /server/src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid/v4' 2 | 3 | /** 4 | * Asynchronously Blocks for n milliseconds 5 | * @param ms n milliseconds 6 | */ 7 | export const waitForMS = (ms: number) => 8 | new Promise(resolve => { 9 | setTimeout(() => { 10 | resolve() 11 | }, ms) 12 | }) 13 | 14 | /** 15 | * Generate a safe pseudo-random token 16 | */ 17 | export const randomToken = () => uuid().replace(/-/g, '') 18 | -------------------------------------------------------------------------------- /client/src/ts/init.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history' 2 | import 'intersection-observer-polyfill' 3 | import { createStore } from './store' 4 | import { checkUser } from './store/user' 5 | 6 | import('./utils/fontAwesome').then() 7 | import './utils/sessionStore' 8 | 9 | export const history = createBrowserHistory() 10 | export const store = createStore(history) 11 | 12 | checkUser()(store.dispatch, store.getState) 13 | -------------------------------------------------------------------------------- /client/src/ts/utils/characteristics.ts: -------------------------------------------------------------------------------- 1 | export const parseCharacteristics: ( 2 | chars: IBeatmapCharacteristic[] 3 | ) => IBeatmapCharacteristic[] = chars => { 4 | return chars.map(({ name, ...rest }) => { 5 | const newName = name 6 | .replace(/([A-Z])/g, ' $1') 7 | .replace(/^./, str => str.toUpperCase()) 8 | .trim() 9 | .replace(/( )/g, ' ') 10 | 11 | return { name: newName, ...rest } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /client/src/ts/components/ExtLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | interface IProps { 4 | href: string 5 | className?: string 6 | } 7 | 8 | export const ExtLink: FunctionComponent = ({ 9 | href, 10 | className, 11 | children, 12 | }) => ( 13 | 19 | {children} 20 | 21 | ) 22 | -------------------------------------------------------------------------------- /docs/content/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | tagline: API Documentation 4 | footer: The content of this site is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. 5 | --- 6 | 7 |
8 | 9 |
10 | 11 | # Table of Contents 12 | * [Using the API](/usage/) 13 | * [Endpoint List](/endpoints/) 14 | * [API Responses](/responses/) 15 | * [Developing](./developing.md) 16 | 17 | -------------------------------------------------------------------------------- /server/src/redis/await.ts: -------------------------------------------------------------------------------- 1 | import { cacheDB, rateLimitDB } from './db' 2 | 3 | export const awaitCacheDB = () => 4 | new Promise(resolve => { 5 | if (cacheDB.status === 'ready') return resolve() 6 | 7 | cacheDB.on('ready', () => resolve()) 8 | }) 9 | 10 | export const awaitRateLimitDB = () => 11 | new Promise(resolve => { 12 | if (rateLimitDB.status === 'ready') return resolve() 13 | else rateLimitDB.on('ready', () => resolve()) 14 | }) 15 | -------------------------------------------------------------------------------- /client/src/sass/_tags.scss: -------------------------------------------------------------------------------- 1 | span.tag.is-easy { 2 | color: white; 3 | background-color: #3cb371; 4 | } 5 | 6 | span.tag.is-normal { 7 | color: white; 8 | background-color: #59b0f4; 9 | } 10 | 11 | span.tag.is-hard { 12 | color: white; 13 | background-color: #ff6347; 14 | } 15 | 16 | span.tag.is-expert { 17 | color: white; 18 | background-color: #bf2a42; 19 | } 20 | 21 | span.tag.is-expert-plus { 22 | color: white; 23 | background-color: #8f48db; 24 | } 25 | -------------------------------------------------------------------------------- /server/src/utils/streams.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'stream' 2 | 3 | export const jsonStream = () => { 4 | let first = true 5 | 6 | return new Transform({ 7 | flush: cb => cb(null, ']\n'), 8 | objectMode: true, 9 | transform: (chunk, _, cb) => { 10 | if (first) { 11 | first = false 12 | return cb(null, `[${JSON.stringify(chunk)}`) 13 | } else { 14 | return cb(null, `,${JSON.stringify(chunk)}`) 15 | } 16 | }, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /client/src/ts/store/user/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux' 2 | import { IUserState, UserActionTypes } from './types' 3 | 4 | const initialState: IUserState = { login: undefined } 5 | 6 | export const userReducer: Reducer> = ( 7 | state = initialState, 8 | action 9 | ) => { 10 | switch (action.type) { 11 | case UserActionTypes.SET_USER: 12 | return { ...state, login: action.payload } 13 | 14 | default: 15 | return state 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { ConnectedRouter } from 'connected-react-router' 2 | import React from 'react' 3 | import { render } from 'react-dom' 4 | import { Provider } from 'react-redux' 5 | 6 | import { App } from './ts/App' 7 | 8 | import './sass/global.scss' 9 | import { history, store } from './ts/init' 10 | 11 | render( 12 | 13 | 14 | 15 | 16 | , 17 | document.getElementById('root') 18 | ) 19 | -------------------------------------------------------------------------------- /server/src/mail/log.ts: -------------------------------------------------------------------------------- 1 | import { MAIL_FROM } from '~environment' 2 | import signale from '~utils/signale' 3 | 4 | const logDriver: MailerFunction = async (to, subject, body) => { 5 | const lines = [ 6 | '-----------------', 7 | `FROM:\t${MAIL_FROM}`, 8 | `TO:\t\t${to}`, 9 | `SUBJECT:\t${subject}`, 10 | 'BODY:', 11 | ...body.split('\n'), 12 | '-----------------', 13 | ] 14 | 15 | for (const line of lines) { 16 | signale.log(line) 17 | } 18 | } 19 | 20 | export default logDriver 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an new feature for BeatSaver Reloaded 4 | 5 | --- 6 | 7 | ## Platform 8 | **This feature request concerns:** *(select all that apply)* 9 | - [ ] Client 10 | - [ ] Server 11 | 12 | ## Details 13 | **Please describe the requested feature in as much detail as possible:** 14 | *Note if your feature request is related to a problem.* 15 | 16 | ## Preferred Solution 17 | **Write a clear and concise description of what you want to happen:** 18 | -------------------------------------------------------------------------------- /client/src/ts/components/APIError.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { TextPage } from './TextPage' 3 | 4 | interface IProps { 5 | error: Error 6 | } 7 | 8 | export const APIError: FunctionComponent = ({ error }) => { 9 | console.error(error) 10 | 11 | return ( 12 | 13 |

Something went wrong, please check back later.

14 |

If the problem persists please alert a site adminstrator.

15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /client/src/ts/store/types.d.ts: -------------------------------------------------------------------------------- 1 | import { Action, Dispatch } from 'redux' 2 | import { IState } from '.' 3 | 4 | declare global { 5 | type Thunk = ( 6 | dispatch: Dispatch>, 7 | getState: () => IState 8 | ) => void 9 | 10 | // tslint:disable-next-line: ban-types 11 | type ThunkFunction = ( 12 | ...args: F extends (...args: infer A) => any ? A : never 13 | ) => void 14 | 15 | interface IAnyAction extends Action { 16 | payload?: P 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/content/endpoints/search.md: -------------------------------------------------------------------------------- 1 | # /search 2 | Allows you to query the database in different ways. 3 | 4 | ## /search/text/:page? 5 | Runs an automatically weighted search based on name, description, beatmap metadata, and uploader username. 6 | Queries are passed using the HTTP query string parameter `q` 7 | 8 | ## /search/advanced/:page? 9 | Runs a search against the Elasticsearch instance using [Lucene syntax](https://lucene.apache.org/core/2_9_4/queryparsersyntax.html). 10 | Queries are passed using the HTTP query string parameter `q` 11 | -------------------------------------------------------------------------------- /client/src/ts/routes/Browse.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { BeatmapList } from '../components/Beatmap' 3 | 4 | export const Latest: FunctionComponent = () => 5 | export const Hot: FunctionComponent = () => 6 | export const Rating: FunctionComponent = () => 7 | export const Plays: FunctionComponent = () => 8 | export const Downloads: FunctionComponent = () => ( 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /client/ssr/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | 3 | dotenv.config() 4 | const { NODE_ENV } = process.env 5 | 6 | const IS_PROD = 7 | NODE_ENV !== undefined && NODE_ENV.toLowerCase() === 'production' 8 | export const IS_DEV = !IS_PROD 9 | 10 | const dbName = 'beatsaver' 11 | export const MONGO_URL = 12 | process.env.MONGO_URL || IS_DEV 13 | ? `mongodb://localhost:27017/${dbName}` 14 | : `mongodb://mongo:27017/${dbName}` 15 | 16 | const defaultPort = 1234 17 | export const PORT = 18 | parseInt(process.env.PORT || `${defaultPort}`, 10) || defaultPort 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug with BeatSaver Reloaded 4 | 5 | --- 6 | 7 | ## Platform 8 | **This bug report concerns:** *(select all that apply)* 9 | - [ ] Client 10 | - [ ] Server 11 | 12 | ## Details 13 | **Please describe the problem you are having in as much detail as possible:** 14 | 15 | ## Reproducing the Problem 16 | **Please include detailed steps to reproduce the problem:** 17 | 18 | ## Further Detail 19 | [Please include screenshots/browser info/anything else relevant] 20 | [Otherwise delete this section] 21 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "es2018", 5 | "sourceMap": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "removeComments": true, 13 | "downlevelIteration": true, 14 | "jsx": "preserve", 15 | "lib": [ 16 | "dom", 17 | "dom.iterable", 18 | "esnext" 19 | ] 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/ts/components/Navbar/NavbarDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | interface IDropdownProps { 4 | label: string 5 | } 6 | 7 | export const NavbarDropdown: FunctionComponent = ({ 8 | label, 9 | children, 10 | }) => ( 11 |
12 | {label} 13 |
{children}
14 |
15 | ) 16 | 17 | export const NavbarDropdownDivider: FunctionComponent = () => ( 18 |
19 | ) 20 | -------------------------------------------------------------------------------- /client/src/ts/components/TextPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import Helmet from 'react-helmet' 3 | 4 | interface IProps { 5 | title: string 6 | pageTitle?: string 7 | } 8 | 9 | export const TextPage: FunctionComponent = ({ 10 | title, 11 | pageTitle, 12 | children, 13 | }) => ( 14 | <> 15 | 16 | {pageTitle || 'BeatSaver'} 17 | 18 | 19 |
20 |

{title}

21 | {children} 22 |
23 | 24 | ) 25 | -------------------------------------------------------------------------------- /docs/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine AS builder 2 | WORKDIR /usr/app 3 | 4 | COPY package.json yarn.lock ./ 5 | RUN apk add --no-cache bash git openssh 6 | RUN yarn install --frozen-lockfile 7 | 8 | COPY . . 9 | RUN yarn run build:prod 10 | 11 | FROM nginx:alpine 12 | 13 | COPY --from=builder /usr/app/content/.vuepress/dist /usr/share/nginx/html 14 | COPY ./nginx.conf /etc/nginx/conf.d/default.conf 15 | 16 | ARG VCS_REF 17 | LABEL org.label-schema.vcs-ref=$VCS_REF \ 18 | org.label-schema.vcs-url="https://github.com/lolPants/beatsaver-reloaded" 19 | 20 | EXPOSE 80 21 | CMD ["nginx", "-g", "daemon off;"] 22 | -------------------------------------------------------------------------------- /server/src/utils/parseKey.ts: -------------------------------------------------------------------------------- 1 | const oldKeyRX = /^\d+-(\d+)$/ 2 | const newKeyRX = /^[0-9a-f]+$/ 3 | 4 | export const parseKey: (key: string, strict?: boolean) => string | false = ( 5 | key, 6 | strict = false 7 | ) => { 8 | if (typeof key !== 'string') return false 9 | 10 | const isOld = key.match(oldKeyRX) 11 | if (isOld !== null) { 12 | if (strict) return false 13 | 14 | const oldKey = isOld[1] 15 | return parseInt(oldKey, 10).toString(16) 16 | } 17 | 18 | const isNew = key.match(newKeyRX) 19 | if (isNew === null) return false 20 | 21 | const k = key.toLowerCase() 22 | return k 23 | } 24 | -------------------------------------------------------------------------------- /client/src/ts/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { Boundary } from './components/Boundary' 3 | import { Footer } from './components/Footer' 4 | import Navbar from './components/Navbar' 5 | import { Routes } from './Routes' 6 | 7 | import './utils/scroll' 8 | 9 | export const App: FunctionComponent = () => ( 10 | <> 11 | 12 | 13 |
14 |
15 | 16 | 17 | 18 |
19 | 20 |
21 |
22 | 23 | ) 24 | -------------------------------------------------------------------------------- /client/src/sass/_input.scss: -------------------------------------------------------------------------------- 1 | div.field label.label { 2 | $transition: 100ms; 3 | 4 | font-size: 0.8em; 5 | font-weight: bold; 6 | text-transform: uppercase; 7 | transition: color $transition ease; 8 | 9 | &::after { 10 | display: inline-block; 11 | content: attr(data-error); 12 | text-transform: initial; 13 | font-style: italic; 14 | font-weight: 400; 15 | 16 | transition: opacity $transition ease, transform $transition ease; 17 | opacity: 0; 18 | transform: translateX(-2px); 19 | } 20 | 21 | &.has-text-danger::after { 22 | opacity: 1; 23 | transform: translateX(4px); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/ts/components/Beatmap/BeatmapList.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { canUseDom } from '../../utils/dom' 3 | import { IBeatmapSearch } from './BeatmapAPI' 4 | import { BeatmapAPI } from './BeatmapAPI' 5 | import { BeatmapScroller } from './BeatmapScroller' 6 | 7 | export const BeatmapList: FunctionComponent = props => { 8 | const isFirefox = canUseDom() 9 | ? navigator.userAgent.toLowerCase().includes('firefox') 10 | : false 11 | 12 | return ( 13 | } 16 | /> 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /docs/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_tokens off; 3 | 4 | listen 80; 5 | server_name localhost; 6 | 7 | root /usr/share/nginx/html; 8 | index index.html index.htm; 9 | 10 | location / { 11 | try_files $uri $uri/ /index.html; 12 | } 13 | 14 | location ~* \.(?:manifest|appcache|html?|xml|json)$ { 15 | expires -1; 16 | } 17 | 18 | location ~* \.(?:css|js)$ { 19 | try_files $uri =404; 20 | expires 1y; 21 | access_log off; 22 | add_header Cache-Control "public"; 23 | } 24 | 25 | location ~* \.(?:png|jpg|jpeg|svg|gif)$ { 26 | try_files $uri =404; 27 | expires 1y; 28 | add_header Cache-Control "public"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_tokens off; 3 | 4 | listen 80; 5 | server_name localhost; 6 | 7 | root /usr/share/nginx/html; 8 | index index.html index.htm; 9 | 10 | location / { 11 | try_files $uri $uri/ /index.html; 12 | } 13 | 14 | location ~* \.(?:manifest|appcache|html?|xml|json)$ { 15 | expires -1; 16 | } 17 | 18 | location ~* \.(?:css|js)$ { 19 | try_files $uri =404; 20 | expires 1y; 21 | access_log off; 22 | add_header Cache-Control "public"; 23 | } 24 | 25 | location ~* \.(?:png|jpg|jpeg|svg|gif)$ { 26 | try_files $uri =404; 27 | expires 1y; 28 | add_header Cache-Control "public"; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/ts/store/images/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux' 2 | import { IImagesState, ImagesActionTypes } from './types' 3 | 4 | const initialState: IImagesState = {} 5 | 6 | export const imagesReducer: Reducer< 7 | IImagesState, 8 | IAnyAction 9 | > = (state = initialState, action) => { 10 | switch (action.type) { 11 | case ImagesActionTypes.RESET_IMAGE: 12 | return { ...state, [action.payload]: undefined } 13 | 14 | case ImagesActionTypes.SET_IMAGE: 15 | return { 16 | ...state, 17 | [action.payload.key]: action.payload.value, 18 | } 19 | 20 | default: 21 | return state 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/src/ts/store/images/actions.ts: -------------------------------------------------------------------------------- 1 | import { ImagesActionTypes } from './types' 2 | 3 | type TypedThunk

= Thunk 4 | 5 | export type LoadImage = ThunkFunction 6 | export const loadImage: ( 7 | url: string 8 | ) => TypedThunk<{ key: string; value: boolean }> = url => async dispatch => { 9 | const setImage = (value: boolean) => { 10 | dispatch({ 11 | payload: { key: url, value }, 12 | type: ImagesActionTypes.SET_IMAGE, 13 | }) 14 | } 15 | 16 | fetch(url) 17 | .then(resp => { 18 | if (resp.ok) setImage(true) 19 | else setImage(false) 20 | }) 21 | .catch(() => setImage(false)) 22 | } 23 | -------------------------------------------------------------------------------- /client/src/ts/remote/response.d.ts: -------------------------------------------------------------------------------- 1 | declare interface IResponse { 2 | docs: T[] 3 | totalDocs: string 4 | 5 | lastPage: number 6 | prevPage: number | null 7 | nextPage: number | null 8 | } 9 | 10 | interface IRespError { 11 | code: number 12 | identifier: string 13 | } 14 | 15 | declare interface IFieldsError extends IRespError { 16 | identifier: 'ERR_INVALID_FIELDS' 17 | 18 | fields?: Array<{ 19 | kind: string 20 | path: string 21 | }> 22 | } 23 | 24 | declare interface IValidationError extends IRespError { 25 | identifier: 'ERR_SCHEMA_VALIDATION_FAILED' 26 | 27 | filename: string 28 | path: string | null 29 | message: string 30 | } 31 | -------------------------------------------------------------------------------- /client/src/ts/routes/Index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { ExtLink } from '../components/ExtLink' 3 | import { TextPage } from '../components/TextPage' 4 | 5 | export const Index: FunctionComponent = () => ( 6 | 7 |

Beat Saber's #1 unofficial beatmap distributor!

8 |
9 | 10 |

11 | Not everything is finished yet with the rewrite, please be patient. 12 |
13 | See{' '} 14 | 15 | this GitHub issue 16 | {' '} 17 | for more info. 18 |

19 | 20 | ) 21 | -------------------------------------------------------------------------------- /server/src/mongo/plugins/withoutKeys.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from 'bson' 2 | import { Schema } from 'mongoose' 3 | 4 | interface IBaseDocument { 5 | _id: ObjectId 6 | __v: number 7 | 8 | [index: string]: any 9 | } 10 | 11 | export const withoutKeys = ( 12 | keys: Readonly, 13 | skipObject: boolean = false 14 | ) => (schema: Schema) => { 15 | const toJSON = schema.get('toJSON') 16 | const toObject = schema.get('toObject') 17 | 18 | const transform = (_: any, document: IBaseDocument) => { 19 | keys.forEach(k => (document[k] = undefined)) 20 | } 21 | 22 | schema.set('toJSON', { ...toJSON, transform }) 23 | if (!skipObject) schema.set('toObject', { ...toObject, transform }) 24 | } 25 | -------------------------------------------------------------------------------- /client/src/sass/_scrollbar.scss: -------------------------------------------------------------------------------- 1 | ::-webkit-scrollbar { 2 | width: 18px; 3 | height: 0; 4 | } 5 | 6 | ::-webkit-scrollbar-thumb { 7 | height: 1em; 8 | border: 6px solid transparent; 9 | background-clip: padding-box; 10 | border-radius: 50px; 11 | box-shadow: inset 0 0 0 1px transparent; 12 | 13 | background-color: rgba(0, 0, 0, 0.6); 14 | } 15 | 16 | ::-webkit-scrollbar-thumb:hover { 17 | background-color: rgba(0, 0, 0, 0.65); 18 | } 19 | 20 | ::-webkit-scrollbar-thumb:active { 21 | background-color: rgba(0, 0, 0, 0.7); 22 | } 23 | 24 | ::-webkit-scrollbar-button { 25 | width: 0; 26 | height: 0; 27 | display: none; 28 | } 29 | 30 | ::-webkit-scrollbar-corner { 31 | background-color: transparent; 32 | } 33 | -------------------------------------------------------------------------------- /server/src/middleware/logger.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from 'koa' 2 | import signale from '~utils/signale' 3 | 4 | export const logger: Middleware = async (ctx, next) => { 5 | await next() 6 | 7 | const httpVersion = `${ctx.req.httpVersionMajor}.${ctx.req.httpVersionMinor}` 8 | 9 | const responseLength = ctx.status === 204 ? 0 : ctx.response.length || -1 10 | const reqInfo = `${ctx.method} ${ctx.url} HTTP/${httpVersion}` 11 | const resInfo = `${ctx.status} ${responseLength}` 12 | const referrer = ctx.headers.referer || ctx.headers.referrer || '-' 13 | const ua = ctx.headers['user-agent'] || '-' 14 | const headers = `"${referrer}" "${ua}"` 15 | 16 | signale.info(`${ctx.realIP} - "${reqInfo}" ${resInfo} ${headers}`) 17 | } 18 | -------------------------------------------------------------------------------- /client/src/ts/store/audio/types.ts: -------------------------------------------------------------------------------- 1 | export enum AudioActionTypes { 2 | PLAY = '@@audio/PLAY', 3 | PAUSE = '@@audio/PAUSE', 4 | STOP = '@@audio/STOP', 5 | 6 | SET_VOLUME = '@@audio/SET_VOLUME', 7 | SET_SOURCE = '@@audio/SET_SOURCE', 8 | SET_LOADING = '@@audio/SET_LOADING', 9 | SET_ERROR = '@@audio/SET_ERROR', 10 | SET_PLAYING_KEY = '@@audio/SET_PLAYING_KEY', 11 | 12 | ADD_AUDIO_URL = '@@audio/ADD_AUDIO_URL', 13 | } 14 | 15 | export type AudioState = 'playing' | 'paused' | 'stopped' 16 | 17 | export interface IAudioState { 18 | state: AudioState 19 | source: string | undefined 20 | volume: number 21 | 22 | loading: boolean 23 | error: Error | null 24 | key: string | null 25 | 26 | urls: { 27 | [key: string]: string 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Jack Baron 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /client/src/ts/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import { ExtLink } from './ExtLink' 4 | 5 | export const Footer: FunctionComponent = () => ( 6 |
7 |
8 |
    9 |
  • 10 | DMCA 11 |
  • 12 |
  • 13 | Privacy 14 |
  • 15 |
  • 16 | 17 | GitHub 18 | 19 |
  • 20 |
  • 21 | License 22 |
  • 23 |
24 |
25 |
26 | ) 27 | -------------------------------------------------------------------------------- /docs/content/.vuepress/styles/index.styl: -------------------------------------------------------------------------------- 1 | kbd 2 | font-size 0.9rem 3 | color #1a1a1a 4 | 5 | .yuu-theme-dark & 6 | background-color #2f2f2f 7 | border-color #2a2a2a 8 | color #f3f3f3 9 | 10 | .icon.outbound 11 | top -2px 12 | left 1px 13 | 14 | .yuu-theme-dark tr:nth-child(2n) 15 | background-color #222 !important 16 | 17 | mark 18 | $alphaFactor = 0.65 19 | 20 | color darken($textColor, 20) 21 | background-color alpha($accentColor, $alphaFactor) 22 | 23 | .yuu-theme-dark & 24 | color $darkTextColor 25 | 26 | .yuu-theme-blue & 27 | background-color alpha($blueAccentColor, $alphaFactor) 28 | 29 | .yuu-theme-red & 30 | background-color alpha($redAccentColor, $alphaFactor) 31 | 32 | .yuu-theme-purple & 33 | background-color alpha($purpleAccentColor, $alphaFactor) 34 | -------------------------------------------------------------------------------- /server/src/utils/CodedError.ts: -------------------------------------------------------------------------------- 1 | import { IS_DEV } from '~environment' 2 | 3 | export default class CodedError extends Error { 4 | public code: number 5 | public identifier: string 6 | public status: number 7 | 8 | public ext: { [key: string]: any } = {} 9 | 10 | constructor( 11 | message: string, 12 | code: number, 13 | identifier: string, 14 | status?: number 15 | ) { 16 | super(message) 17 | 18 | this.code = code 19 | this.identifier = identifier 20 | this.status = status || 500 21 | } 22 | 23 | public get statusCode() { 24 | return this.status 25 | } 26 | 27 | public get body() { 28 | const { message, code, identifier, ext } = this 29 | return IS_DEV 30 | ? { message, code, identifier, ...ext } 31 | : { code, identifier, ...ext } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 21 3 | 4 | # Number of days of inactivity before a stale issue is closed 5 | daysUntilClose: 7 6 | 7 | # Issues with these labels will never be considered stale 8 | exemptLabels: 9 | - pinned 10 | - confirmed 11 | - critial 12 | - 'priority:high' 13 | 14 | # Label to use when marking an issue as stale 15 | staleLabel: stale 16 | 17 | # Comment to post when marking an issue as stale. Set to `false` to disable 18 | markComment: > 19 | This issue has been automatically marked as stale because it has not had 20 | recent activity. It will be closed if no further activity occurs. Thank you 21 | for your contributions. 22 | 23 | # Comment to post when closing a stale issue. Set to `false` to disable 24 | closeComment: false 25 | -------------------------------------------------------------------------------- /server/src/strategies/local.ts: -------------------------------------------------------------------------------- 1 | import { compare } from 'bcrypt' 2 | import passport from 'passport' 3 | import { Strategy as LocalStrategy } from 'passport-local' 4 | import { User } from '~mongo/models' 5 | import CodedError from '~utils/CodedError' 6 | 7 | const ERR_INVALID_USERNAME = new CodedError( 8 | 'invalid username', 9 | 0x40001, 10 | 'ERR_INVALID_USERNAME', 11 | 404 12 | ) 13 | 14 | passport.use( 15 | new LocalStrategy(async (username, password, cb) => { 16 | try { 17 | const user = await User.findOne({ username }) 18 | if (!user) return cb(ERR_INVALID_USERNAME, false) 19 | 20 | const valid = await compare(password, user.password) 21 | if (!valid) return cb(null, false) 22 | 23 | return cb(null, user) 24 | } catch (err) { 25 | return cb(err) 26 | } 27 | }) 28 | ) 29 | -------------------------------------------------------------------------------- /client/src/sass/_layout.scss: -------------------------------------------------------------------------------- 1 | html, body, div#root { 2 | height: 100%; 3 | overflow: overlay; 4 | } 5 | 6 | @media screen and (max-width: 1023px) { 7 | div.container.side-pad { 8 | width: 100%; 9 | padding: 0 20px; 10 | } 11 | } 12 | 13 | div.layout { 14 | display: flex; 15 | flex-direction: column; 16 | height: 100%; 17 | } 18 | 19 | div.container.has-footer { 20 | margin-top: 24px; 21 | flex-grow: 1; 22 | } 23 | 24 | footer.sticky-footer { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | 29 | padding: 12px; 30 | font-weight: bold; 31 | } 32 | 33 | div.container.pad { 34 | margin-top: 24px; 35 | margin-bottom: 36px; 36 | } 37 | 38 | div.thin { 39 | max-width: 600px; 40 | width: 100%; 41 | margin: 0 auto; 42 | } 43 | 44 | .is-fullwidth { 45 | width: 100%; 46 | } 47 | -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | BeatSaver 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /client/src/ts/store/scrollers/types.ts: -------------------------------------------------------------------------------- 1 | import { QueryType, SearchType } from '../../components/Beatmap/BeatmapAPI' 2 | 3 | export enum ScrollerActionTypes { 4 | INIT_SCROLLER = '@@scrollers/INIT_SCROLLER', 5 | RESET_SCROLLER = '@@scrollers/RESET_SCROLLER', 6 | 7 | SET_LOADING = '@@scrollers/SET_LOADING', 8 | SET_ERROR = '@@scrollers/SET_ERROR', 9 | SET_DONE = '@@scrollers/SET_DONE', 10 | SET_LAST_PAGE = '@@scrollers/SET_LAST_PAGE', 11 | APPEND_MAPS = '@@scrollers/APPEND_MAPS', 12 | } 13 | 14 | export interface IScroller { 15 | key: string 16 | 17 | type: QueryType | SearchType 18 | query: string | undefined 19 | 20 | error: Error | undefined 21 | loading: boolean 22 | done: boolean 23 | 24 | lastPage: number | null 25 | maps: IBeatmap[] 26 | } 27 | 28 | export interface IScrollersState { 29 | [key: string]: IScroller | undefined 30 | } 31 | -------------------------------------------------------------------------------- /client/src/ts/components/Beatmap/DiffTags.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, FunctionComponent } from 'react' 2 | 3 | interface IProps { 4 | easy?: boolean 5 | normal?: boolean 6 | hard?: boolean 7 | expert?: boolean 8 | expertPlus?: boolean 9 | 10 | style?: CSSProperties 11 | } 12 | 13 | export const DiffTags: FunctionComponent = ({ 14 | easy, 15 | normal, 16 | hard, 17 | expert, 18 | expertPlus, 19 | 20 | style, 21 | }) => ( 22 |
23 | {easy ? Easy : null} 24 | {normal ? Normal : null} 25 | {hard ? Hard : null} 26 | {expert ? Expert : null} 27 | {expertPlus ? Expert+ : null} 28 |
29 | ) 30 | -------------------------------------------------------------------------------- /client/src/ts/routes/Legacy.tsx: -------------------------------------------------------------------------------- 1 | import { replace as replaceFn } from 'connected-react-router' 2 | import { FunctionComponent } from 'react' 3 | import { connect } from 'react-redux' 4 | import { RouteComponentProps } from 'react-router-dom' 5 | 6 | interface IProps { 7 | replace: typeof replaceFn 8 | } 9 | 10 | interface IParams { 11 | key: string 12 | } 13 | 14 | const Legacy: FunctionComponent> = ({ 15 | match, 16 | replace, 17 | }) => { 18 | try { 19 | const [, oldKey] = match.params.key.split('-') 20 | const newKey = parseInt(oldKey, 10).toString(16) 21 | 22 | replace(`/beatmap/${newKey}`) 23 | } catch (err) { 24 | replace('/') 25 | } 26 | 27 | return null 28 | } 29 | 30 | const ConnectedLegacy = connect(null, { 31 | replace: replaceFn, 32 | })(Legacy) 33 | 34 | export { ConnectedLegacy as Legacy } 35 | -------------------------------------------------------------------------------- /client/src/ts/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import Axios, { AxiosInstance } from 'axios' 2 | import { IS_DEV } from './env' 3 | 4 | export const axios: AxiosInstance = Axios.create({ 5 | baseURL: IS_DEV ? 'http://localhost:3000' : '/api', 6 | }) 7 | 8 | export const axiosSWR = async (key: string) => { 9 | const resp = await axios.get(key) 10 | return resp.data 11 | } 12 | 13 | axios.interceptors.request.use( 14 | config => { 15 | const token = localStorage.getItem('token') 16 | if (token) config.headers.Authorization = `Bearer ${token}` 17 | 18 | return config 19 | }, 20 | err => Promise.reject(err) 21 | ) 22 | 23 | axios.interceptors.response.use( 24 | resp => { 25 | if (resp.headers['x-auth-token']) { 26 | const token: string = resp.headers['x-auth-token'] 27 | localStorage.setItem('token', token) 28 | } 29 | 30 | return resp 31 | }, 32 | err => Promise.reject(err) 33 | ) 34 | -------------------------------------------------------------------------------- /client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tslint", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-plugin-prettier", 6 | "tslint-config-prettier" 7 | ], 8 | "rules": { 9 | "max-line-length": { 10 | "options": [120] 11 | }, 12 | "new-parens": true, 13 | "no-arg": true, 14 | "no-bitwise": true, 15 | "no-conditional-assignment": true, 16 | "no-consecutive-blank-lines": false, 17 | "no-console": { 18 | "severity": "warning", 19 | "options": ["debug", "info", "log", "time", "timeEnd", "trace"] 20 | }, 21 | "curly": [true, "ignore-same-line"], 22 | "no-invalid-this": true, 23 | "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case", "allow-leading-underscore"], 24 | "prettier": true 25 | }, 26 | "jsRules": { 27 | "max-line-length": { 28 | "options": [120] 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # VSCode 36 | .vscode 37 | 38 | # NPM 39 | node_modules 40 | .npm 41 | npm-debug.log 42 | 43 | # git 44 | .git 45 | .gitignore 46 | LICENSE 47 | README.md 48 | 49 | # Docker 50 | Dockerfile 51 | .dockerignore 52 | 53 | # Parcel Output 54 | dist 55 | .cache 56 | *.env 57 | -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | 35 | # VSCode 36 | .vscode 37 | 38 | # NPM 39 | node_modules 40 | .npm 41 | npm-debug.log 42 | 43 | # git 44 | .git 45 | .gitignore 46 | LICENSE 47 | README.md 48 | 49 | # Docker 50 | Dockerfile 51 | .dockerignore 52 | 53 | # Application 54 | build 55 | *.env 56 | cdn 57 | -------------------------------------------------------------------------------- /server/src/koa.ts: -------------------------------------------------------------------------------- 1 | import cors from '@koa/cors' 2 | import Koa from 'koa' 3 | import helmet from 'koa-helmet' 4 | import Router from 'koa-router' 5 | import { IS_DEV } from '~environment' 6 | import { cacheHeaders, errorHandler, logger, realIP } from '~middleware' 7 | import { routes } from '~routes' 8 | 9 | export const app = new Koa() 10 | const router = new Router() 11 | 12 | if (!IS_DEV) app.proxy = true 13 | else { 14 | app.use( 15 | cors({ 16 | exposeHeaders: [ 17 | 'x-auth-token', 18 | 'rate-limit-remaining', 19 | 'rate-limit-reset', 20 | 'rate-limit-total', 21 | ], 22 | }) 23 | ) 24 | } 25 | 26 | app 27 | .use(realIP) 28 | .use(helmet({ hsts: false })) 29 | .use(logger) 30 | .use(errorHandler) 31 | .use(cacheHeaders) 32 | 33 | router.get('/health', ctx => (ctx.status = 200)) 34 | routes.forEach(r => router.use(r.routes(), r.allowedMethods())) 35 | app.use(router.routes()).use(router.allowedMethods()) 36 | -------------------------------------------------------------------------------- /client/src/sass/_navbar.scss: -------------------------------------------------------------------------------- 1 | div.navbar-item.navbar-divi { 2 | user-select: none; 3 | 4 | $divi-color: rgba($tex, 0.4); 5 | &::after { 6 | color: $divi-color; 7 | content: '|'; 8 | } 9 | 10 | @media screen and (max-width: 1023px) { 11 | &::after { 12 | content: ' '; 13 | position: absolute; 14 | 15 | $padding: 12px; 16 | left: $padding; 17 | right: $padding; 18 | 19 | height: 2px; 20 | background-color: $divi-color; 21 | } 22 | 23 | width: 100%; 24 | } 25 | } 26 | 27 | @media screen and (max-width: 1023px) { 28 | .navbar-menu { 29 | background-color: darken($dark, 4); 30 | } 31 | 32 | a.navbar-item, .navbar-link { 33 | color: darken($tex, 12); 34 | } 35 | 36 | a.navbar-item:hover, a.navbar-item.is-active, .navbar-link:hover, .navbar-link.is-active { 37 | color: $tex; 38 | background-color: lighten($dark, 2); 39 | } 40 | 41 | .navbar-link:not(.is-arrowless)::after { 42 | border-color: $tex; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /server/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tslint", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-plugin-prettier", 6 | "tslint-config-prettier" 7 | ], 8 | "rulesDirectory": ["node_modules/tslint-microsoft-contrib"], 9 | "rules": { 10 | "max-line-length": { 11 | "options": [120] 12 | }, 13 | "new-parens": true, 14 | "no-arg": true, 15 | "no-bitwise": true, 16 | "no-conditional-assignment": true, 17 | "no-consecutive-blank-lines": false, 18 | "no-console": { 19 | "severity": "warning", 20 | "options": ["debug", "info", "log", "time", "timeEnd", "trace"] 21 | }, 22 | "curly": [true, "ignore-same-line"], 23 | "no-invalid-this": true, 24 | "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case", "allow-leading-underscore"], 25 | "prettier": true, 26 | "no-suspicious-comment": true 27 | }, 28 | "jsRules": { 29 | "max-line-length": { 30 | "options": [120] 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine AS builder 2 | WORKDIR /usr/app 3 | 4 | COPY package.json yarn.lock ./ 5 | RUN apk add --no-cache bash git openssh make gcc g++ python 6 | RUN yarn install --frozen-lockfile 7 | 8 | COPY . . 9 | RUN yarn test && yarn build 10 | 11 | FROM node:10-alpine 12 | ENV NODE_ENV=production 13 | 14 | RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app 15 | WORKDIR /home/node/app 16 | 17 | COPY package.json yarn.lock ./ 18 | RUN apk add --no-cache tini bash git openssh make gcc g++ python && \ 19 | yarn install --frozen-lockfile --production && \ 20 | yarn run modclean && \ 21 | apk del bash git openssh make gcc g++ python 22 | 23 | USER node 24 | COPY --from=builder --chown=node:node /usr/app/build ./build 25 | 26 | ARG VCS_REF 27 | LABEL org.label-schema.vcs-ref=$VCS_REF \ 28 | org.label-schema.vcs-url="https://github.com/lolPants/beatsaver-reloaded" 29 | 30 | VOLUME ["/home/node/app/cdn"] 31 | ENTRYPOINT ["/sbin/tini", "--"] 32 | EXPOSE 3000 33 | CMD ["node", "."] 34 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # JSON Web Token Signing Secret 2 | # In prod this should be a long (>32 char) random string 3 | JWT_SECRET= 4 | 5 | # Caching Driver 6 | # Can be redis or none 7 | CACHE_DRIVER=redis 8 | 9 | # Redis Details 10 | # Defaults to localhost in dev, and redis in prod 11 | # REDIS_HOST= 12 | # REDIS_PASSWORD= 13 | # REDIS_PORT= 14 | 15 | # Elasticsearch Details 16 | # Defaults to localhost in dev, and elastic in prod 17 | # ELASTIC_DISABLED=true 18 | # ELASTIC_HOST= 19 | # ELASTIC_PORT= 20 | 21 | # Mail Details 22 | # Driver can be set to "log" or "sendgrid" 23 | MAIL_DRIVER=log 24 | MAIL_FROM=BeatSaver 25 | 26 | # Sendgrid Key 27 | # Required if mail driver is set to "sendgrid" 28 | SENDGRID_KEY= 29 | 30 | # BCrypt rounds 31 | # Defaults to 12 if not set 32 | # BCRYPT_ROUNDS=12 33 | 34 | # Results per page 35 | # Defaults to 10 if not set 36 | # RESULTS_PER_PAGE=10 37 | 38 | # Steam Web API Key 39 | # Required for Steam Voting 40 | STEAM_API_KEY= 41 | 42 | # Disable Nightly Dumps 43 | # DISABLE_DUMPS=true 44 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine AS builder 2 | WORKDIR /usr/app 3 | 4 | RUN apk add --no-cache bash git openssh util-linux 5 | COPY package.json yarn.lock ./ 6 | RUN yarn install --frozen-lockfile 7 | 8 | COPY . . 9 | RUN yarn build:prod 10 | RUN yarn build:ssr 11 | 12 | FROM node:10-alpine 13 | ENV NODE_ENV=production 14 | 15 | RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app 16 | WORKDIR /home/node/app 17 | 18 | COPY package.json yarn.lock ./ 19 | RUN apk add --no-cache tini bash git openssh make gcc g++ python && \ 20 | yarn install --frozen-lockfile --production && \ 21 | apk del bash git openssh make gcc g++ python 22 | 23 | USER node 24 | COPY --from=builder --chown=node:node /usr/app/dist/client ./public 25 | COPY --from=builder --chown=node:node /usr/app/dist/ssr ./build 26 | 27 | ARG VCS_REF 28 | LABEL org.label-schema.vcs-ref=$VCS_REF \ 29 | org.label-schema.vcs-url="https://github.com/lolPants/beatsaver-reloaded" 30 | 31 | ENTRYPOINT ["/sbin/tini", "--"] 32 | EXPOSE 1234 33 | CMD ["node", "."] 34 | -------------------------------------------------------------------------------- /server/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import Router from 'koa-router' 2 | import { IS_DEV } from '~environment' 3 | import signale from '~utils/signale' 4 | import { adminRouter } from './admin' 5 | import { authRouter } from './auth' 6 | import { cdnRouter } from './cdn' 7 | import { downloadRouter } from './download' 8 | import { legalRouter } from './legal' 9 | import { manageRouter } from './manage' 10 | import { mapsRouter } from './maps' 11 | import { searchRouter } from './search' 12 | import { statsRouter } from './stats' 13 | import { uploadRouter } from './upload' 14 | import { usersRouter } from './users' 15 | import { voteRouter } from './vote' 16 | 17 | export const routes: Router[] = [ 18 | adminRouter, 19 | authRouter, 20 | downloadRouter, 21 | legalRouter, 22 | manageRouter, 23 | mapsRouter, 24 | searchRouter, 25 | statsRouter, 26 | uploadRouter, 27 | usersRouter, 28 | voteRouter, 29 | ] 30 | 31 | if (IS_DEV) { 32 | signale.warn('Enabling local CDN... Do not use this in production!') 33 | routes.push(cdnRouter) 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation CI 2 | on: 3 | push: 4 | paths: 5 | - 'docs/*' 6 | - 'docs/**/*' 7 | - '.github/workflows/docs.yml' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Build Docker image 15 | run: | 16 | DOCKER_REPO=`echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]'`; 17 | DOCKER_TAG=`git rev-parse --short HEAD`; 18 | docker build . --file Dockerfile --build-arg VCS_REF="$DOCKER_TAG" --tag "docker.pkg.github.com/$DOCKER_REPO/docs:$DOCKER_TAG" 19 | working-directory: ./docs 20 | - name: Login to Docker Registry 21 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login docker.pkg.github.com --username lolpants --password-stdin 22 | - name: Push to Docker Registry 23 | run: | 24 | DOCKER_REPO=`echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]'`; 25 | docker push "docker.pkg.github.com/$DOCKER_REPO/docs" 26 | if: github.ref == 'refs/heads/master' 27 | -------------------------------------------------------------------------------- /client/src/ts/remote/download.ts: -------------------------------------------------------------------------------- 1 | import { saveAs } from 'file-saver' 2 | import sanitize from 'sanitize-filename' 3 | import { mutate } from 'swr' 4 | 5 | export class DownloadError extends Error { 6 | public code: number 7 | 8 | constructor(message: string, code: number) { 9 | super(message) 10 | this.code = code 11 | } 12 | } 13 | 14 | export const downloadBeatmap = async ( 15 | map: IBeatmap, 16 | direct: boolean = false 17 | ) => { 18 | const downloadURL = direct ? map.directDownload : map.downloadURL 19 | const resp = await fetch(downloadURL) 20 | 21 | mutate(`/stats/key/${map.key}`, { 22 | ...map, 23 | stats: { ...map.stats, downloads: map.stats.downloads + 1 }, 24 | }) 25 | 26 | if (!resp.ok) throw new DownloadError('download failed', resp.status) 27 | const blob = await resp.blob() 28 | 29 | const songName = sanitize(map.metadata.songName) 30 | const authorName = sanitize(map.metadata.levelAuthorName) 31 | const filename = `${map.key} (${songName} - ${authorName}).zip` 32 | 33 | saveAs(blob, filename) 34 | } 35 | -------------------------------------------------------------------------------- /server/src/redis/db.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | import { REDIS_HOST, REDIS_PASSWORD, REDIS_PORT } from '~environment' 3 | import signale from '~utils/signale' 4 | import { CACHE_DB, RATE_LIMIT_DB } from './constants' 5 | 6 | export const rateLimitDB = new Redis({ 7 | db: RATE_LIMIT_DB, 8 | host: REDIS_HOST, 9 | password: REDIS_PASSWORD, 10 | port: REDIS_PORT, 11 | }) 12 | 13 | rateLimitDB 14 | .on('ready', () => signale.info('Connected to Ratelimit KV Store')) 15 | .on('reconnecting', () => 16 | signale.warn('Reconnecting to Ratelimit KV Store...') 17 | ) 18 | .on('error', () => { 19 | signale.error('Failed to connect to Ratelimit KV Store') 20 | process.exit(1) 21 | }) 22 | 23 | export const cacheDB = new Redis({ 24 | db: CACHE_DB, 25 | host: REDIS_HOST, 26 | password: REDIS_PASSWORD, 27 | port: REDIS_PORT, 28 | }) 29 | 30 | cacheDB 31 | .on('ready', () => signale.info('Connected to Cache KV Store')) 32 | .on('reconnecting', () => signale.warn('Reconnecting to Cache KV Store...')) 33 | .on('error', () => { 34 | signale.error('Failed to connect to Cache KV Store') 35 | process.exit(1) 36 | }) 37 | -------------------------------------------------------------------------------- /server/src/strategies/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import passport from 'passport' 3 | import { ExtractJwt, Strategy as JWTStrategy } from 'passport-jwt' 4 | import { JWT_SECRET } from '~environment' 5 | import { IUserModel, User } from '~mongo/models' 6 | 7 | export interface IAuthToken { 8 | _id: string 9 | } 10 | 11 | passport.use( 12 | new JWTStrategy( 13 | { 14 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 15 | secretOrKey: JWT_SECRET, 16 | }, 17 | async (payload: IAuthToken, cb) => { 18 | try { 19 | const user = await User.findById(payload._id) 20 | if (!user) return cb(null, false) 21 | 22 | return cb(null, user) 23 | } catch (err) { 24 | return cb(err) 25 | } 26 | } 27 | ) 28 | ) 29 | 30 | export const issueToken: (user: IUserModel) => Promise = user => 31 | new Promise((resolve, reject) => { 32 | const payload: IAuthToken = { _id: user._id } 33 | 34 | jwt.sign(payload, JWT_SECRET, { expiresIn: '7 days' }, (err, token) => { 35 | if (err) return reject(err) 36 | else return resolve(token) 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beatsaver/docs", 3 | "version": "0.1.0", 4 | "description": "BeatSaver Reloaded Documentation", 5 | "author": "Jack Baron (https://www.jackbaron.com)", 6 | "license": "ISC", 7 | "private": true, 8 | "scripts": { 9 | "lint": "eslint . --ext js,md,vue", 10 | "dev": "vuepress dev content", 11 | "build": "vuepress build content", 12 | "build:prod": "cross-env NODE_ENV=production yarn run build" 13 | }, 14 | "devDependencies": { 15 | "@vuepress/plugin-last-updated": "^1.2.0", 16 | "@vuepress/plugin-medium-zoom": "^1.2.0", 17 | "@vuepress/plugin-nprogress": "^1.2.0", 18 | "@vuepress/plugin-search": "^1.2.0", 19 | "babel-eslint": "^10.0.3", 20 | "cross-env": "^6.0.3", 21 | "dateformat": "^3.0.3", 22 | "eslint": "^5.6.0", 23 | "eslint-config-sora": "^2.1.0", 24 | "eslint-plugin-markdown": "^1.0.1", 25 | "eslint-plugin-vue": "^5.2.2", 26 | "markdown-it-footnote": "^3.0.2", 27 | "markdown-it-mark": "^3.0.0", 28 | "vuepress": "^1.2.0", 29 | "vuepress-plugin-container": "^2.1.2", 30 | "vuepress-theme-yuu": "^2.2.1" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to BeatSaver Reloaded 2 | If you wish to contribute to the codebase, feel free to fork the repository and submit a pull request. We use TSLint and Prettier to enforce a consistent coding style, so having that set up in your editor of choice is a great boon to your development process. 3 | 4 | ## Submit bug reports or feature requests 5 | Just use the GitHub issue tracker to submit your bug reports and feature requests. When submitting, please use the [issue templates](https://github.com/lolPants/beatsaver-reloaded/issues/new/choose). 6 | 7 | ## Development Prerequisites 8 | - Node.js (>= v10.0.0) and `yarn` 9 | - MongoDB server running locally on port `27017` 10 | - Redis server running locally on port `6379` 11 | 12 | ## Setup 13 | 1. Fork & clone the repository, then setup a feature branch 14 | 2. Copy `.env.example` to `server/.env` and fill out the relevant keys 15 | 3. Run `yarn` in the `client` and the `server` directories to install dependencies 16 | 4. Write some code! 17 | 5. `yarn test` in the `client` / `server` directories to run Type and Lint checks 18 | 6. [Submit a pull request](https://github.com/lolPants/beatsaver-reloaded/compare) 19 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "target": "es2017", 5 | "skipLibCheck": true, 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "removeComments": true, 13 | "downlevelIteration": true, 14 | "importHelpers": true, 15 | "sourceMap": true, 16 | "outDir": "./build", 17 | "rootDir": "./src", 18 | "baseUrl": ".", 19 | "paths": { 20 | "~mail": ["./src/mail"], 21 | "~middleware": ["./src/middleware"], 22 | "~mongo/*": ["./src/mongo/*"], 23 | "~redis": ["./src/redis"], 24 | "~routes": ["./src/routes"], 25 | "~strategies": ["./src/strategies"], 26 | "~utils/*": ["./src/utils/*"], 27 | "~constants": ["./src/constants"], 28 | "~environment": ["./src/env"] 29 | }, 30 | "plugins": [{ "transform": "typescript-transform-paths" }] 31 | }, 32 | "include": [ 33 | "src/**/*.ts" 34 | ], 35 | "exclude": [ 36 | "node_modules", 37 | "build" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /server/src/routes/legal.ts: -------------------------------------------------------------------------------- 1 | import cors from '@koa/cors' 2 | import { ParameterizedContext } from 'koa' 3 | import Router from 'koa-router' 4 | import { join } from 'path' 5 | import { LEGAL_PATH } from '~constants' 6 | import { cache, rateLimit } from '~middleware' 7 | import { exists, readFile } from '~utils/fs' 8 | 9 | const router = new Router({ 10 | prefix: '/legal', 11 | }) 12 | .use(cors()) 13 | .use( 14 | rateLimit({ 15 | duration: 10 * 1000, 16 | max: 200, 17 | }) 18 | ) 19 | .use(cache({ prefix: 'legal', expire: 60 * 15 })) 20 | 21 | const serveMiddleware = async (ctx: ParameterizedContext, path: string) => { 22 | const fileExists = await exists(path) 23 | if (fileExists === false) return (ctx.status = 501) 24 | 25 | const content = await readFile(path, 'utf8') 26 | if (content.trim() === '') return (ctx.status = 501) 27 | 28 | return (ctx.body = content.trim()) 29 | } 30 | 31 | router.get('/dmca', async ctx => { 32 | const dmcaPath = join(LEGAL_PATH, 'DMCA.md') 33 | return serveMiddleware(ctx, dmcaPath) 34 | }) 35 | 36 | router.get('/privacy', async ctx => { 37 | const dmcaPath = join(LEGAL_PATH, 'PRIVACY.md') 38 | return serveMiddleware(ctx, dmcaPath) 39 | }) 40 | 41 | export { router as legalRouter } 42 | -------------------------------------------------------------------------------- /client/src/ts/store/scrollers/actions.ts: -------------------------------------------------------------------------------- 1 | import { SearchTypes } from '../../components/Beatmap/BeatmapAPI' 2 | import { request } from '../../remote/request' 3 | import { IScroller, ScrollerActionTypes } from './types' 4 | 5 | type TypedThunk

= Thunk 6 | 7 | export type InitializeScroller = ThunkFunction 8 | export const initializeScroller: ( 9 | key: string, 10 | type: SearchTypes, 11 | query: string | undefined 12 | ) => TypedThunk = (key, type, query) => (dispatch, getState) => { 13 | const defaultScroller: IScroller = { 14 | key, 15 | query, 16 | type, 17 | 18 | done: false, 19 | error: undefined, 20 | lastPage: null, 21 | loading: false, 22 | maps: [], 23 | } 24 | 25 | if (getState().scrollers[key] === undefined) { 26 | dispatch({ 27 | payload: defaultScroller, 28 | type: ScrollerActionTypes.INIT_SCROLLER, 29 | }) 30 | } 31 | } 32 | 33 | export type RequestNextMaps = ThunkFunction 34 | export const requestNextMaps: ( 35 | key: string, 36 | type: SearchTypes, 37 | query: string | undefined 38 | ) => TypedThunk = (key, type, query) => async (dispatch, getState) => { 39 | request(dispatch, getState, key, type, query) 40 | } 41 | -------------------------------------------------------------------------------- /client/ssr/generateHTML.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs' 2 | import { encode } from 'he' 3 | import { join } from 'path' 4 | 5 | export const root = 6 | process.env.NODE_ENV !== 'production' 7 | ? join(__dirname, '..', 'client') 8 | : join(__dirname, '..', 'public') 9 | 10 | export const htmlPath = join(root, 'index.html') 11 | export const html = readFileSync(htmlPath, 'utf8') 12 | 13 | interface IOpenGraphOptions { 14 | siteName: string 15 | title: string 16 | url: string 17 | 18 | description?: string 19 | image?: string 20 | } 21 | 22 | export const generateOpenGraph = (options: IOpenGraphOptions) => { 23 | const lines = [ 24 | ``, 25 | ``, 26 | ``, 27 | ``, 28 | options.description 29 | ? `` 32 | : undefined, 33 | options.image 34 | ? `` 35 | : undefined, 36 | ] 37 | 38 | return lines.filter(x => x !== undefined).join('') 39 | } 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Proposed Changes 2 | Please describe the changes this PR makes and why it should be merged. If it fixes a bug or resolves a feature request, be sure to reference that issue so it can be closed automatically. 3 | 4 | ## Platforms 5 | This pull request modifies: *(select all that apply)* 6 | - [ ] Client 7 | - [ ] Server 8 | 9 | ## Types of Changes 10 | This pull request is contains: *(select all that apply)* 11 | - [ ] Bug fixes *(non-breaking change which fixes an issue)* 12 | - [ ] New features *(non-breaking change which adds functionality)* 13 | - [ ] Breaking changes *(fix or feature that would cause existing functionality to not work as expected)* 14 | 15 | ## Checklist 16 | - [ ] I have read the [contribution guidelines](https://github.com/lolPants/beatsaver-reloaded/blob/master/.github/CONTRIBUTING.md) 17 | - [ ] I have checked the changes adhere to the project's style guide and all tests pass 18 | - [ ] I have (to the best of my ability) checked for vulnerabilities, or any ways that my code could be abused and concluded that it is safe to run in production 19 | 20 | ## Further Comments 21 | If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... 22 | -------------------------------------------------------------------------------- /client/src/ts/utils/formatDate.ts: -------------------------------------------------------------------------------- 1 | import dateFormat from 'dateformat' 2 | import { SEVEN_DAYS } from '../constants' 3 | 4 | interface IInterval { 5 | label: string 6 | seconds: number 7 | } 8 | 9 | const intervals: readonly IInterval[] = [ 10 | { label: 'year', seconds: 60 * 60 * 24 * 365 }, 11 | { label: 'month', seconds: 60 * 60 * 24 * 31 }, 12 | { label: 'week', seconds: 60 * 60 * 24 * 7 }, 13 | { label: 'day', seconds: 60 * 60 * 24 }, 14 | { label: 'hour', seconds: 60 * 60 }, 15 | { label: 'minute', seconds: 60 }, 16 | { label: 'second', seconds: 1 }, 17 | ] 18 | 19 | const timeSince = (date: Date | string) => { 20 | const d = new Date(date) 21 | 22 | const seconds = Math.floor((Date.now() - d.getTime()) / 1000) 23 | if (seconds === 0) return 'Just now' 24 | 25 | const interval = intervals.find(i => i.seconds < seconds) 26 | if (interval === undefined) return d.toISOString() 27 | 28 | const count = Math.floor(seconds / interval.seconds) 29 | return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago` 30 | } 31 | 32 | export const formatDate = (date: Date | string) => { 33 | const d = typeof date === 'string' ? new Date(date) : date 34 | 35 | return Date.now() - d.getTime() < SEVEN_DAYS 36 | ? timeSince(d) 37 | : dateFormat(d, 'yyyy/mm/dd') 38 | } 39 | -------------------------------------------------------------------------------- /client/src/ts/components/Beatmap/Statistic.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | 3 | interface IStatTextProps { 4 | type: 'text' 5 | text: string 6 | emoji: string 7 | } 8 | 9 | interface IStatNumberProps { 10 | type: 'num' 11 | number: number 12 | emoji: string 13 | 14 | fixed?: number 15 | percentage?: boolean 16 | } 17 | 18 | interface IStatCommonProps { 19 | hover?: string 20 | } 21 | 22 | type IStatProps = (IStatTextProps | IStatNumberProps) & IStatCommonProps 23 | 24 | export const Statistic: FunctionComponent = props => { 25 | if (props.type === 'num') { 26 | const { number: n, emoji, fixed, percentage, hover } = props 27 | 28 | const multiplied = percentage !== undefined ? n * 100 : n 29 | const num = 30 | fixed !== undefined 31 | ? multiplied.toLocaleString(undefined, { maximumFractionDigits: 1 }) 32 | : multiplied.toLocaleString() 33 | 34 | return ( 35 |

  • 36 | {num} 37 | {percentage !== undefined ? '%' : ''} {emoji} 38 |
  • 39 | ) 40 | } else if (props.type === 'text') { 41 | const { text, emoji, hover } = props 42 | 43 | return ( 44 |
  • 45 | {text} {emoji} 46 |
  • 47 | ) 48 | } 49 | 50 | return null 51 | } 52 | -------------------------------------------------------------------------------- /docs/content/endpoints/maps.md: -------------------------------------------------------------------------------- 1 | # /maps 2 | The `maps` endpoint has 6 routes that allow you to retrieve Beatmap information in different ordering configurations. 3 | 4 | ## /maps/detail/:key 5 | Given a Beatmap Key, this endpoint will return the Beatmap from the database. 6 | 7 | ## /maps/by-hash/:hash 8 | Given a Beatmap Hash, this endpoint will return the Beatmap from the database. 9 | 10 | ## /maps/uploader/:id/:page? 11 | Given a user's ID, this endpoint will return all Beatmaps which they've created. 12 | 13 | ## /maps/hot/:page? 14 | Returns an array of Beatmaps from the database, sorted in descending order by the `heat` parameter of the `stats` field of the Beatmap. 15 | 16 | ## /maps/rating/:page? 17 | Returns an array of Beatmaps from the database, sorted in descending order by the `rating` parameter of the `stats` field of the Beatmap. 18 | 19 | ## /maps/latest/:page? 20 | Returns an array of Beatmaps from the database, sorted in descending order by the `uploaded` parameter (most recent uploads come first) 21 | 22 | ## /maps/downloads/:page? 23 | Returns an array of Beatmaps from the database, sorted in descending order by the `downloads` parameter of the `stats` field of the Beatmap. 24 | 25 | ## /maps/plays/:page? 26 | Returns an array of Beatmaps from the database, sorted in descending order by the `plays` parameter of the `stats` field of the Beatmap. 27 | -------------------------------------------------------------------------------- /client/src/ts/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useEffect } from 'react' 2 | import { connect, MapStateToProps } from 'react-redux' 3 | import { IState } from '../store' 4 | import { LoadImage, loadImage as loadImageFn } from '../store/images' 5 | 6 | import Placeholder from '../../images/placeholder.svg' 7 | 8 | interface IPassedProps { 9 | src: string 10 | alt?: string 11 | draggable?: boolean 12 | } 13 | 14 | interface IConnectedProps { 15 | valid: boolean | undefined 16 | } 17 | 18 | interface IDispatchProps { 19 | loadImage: LoadImage 20 | } 21 | 22 | type IProps = IPassedProps & IConnectedProps & IDispatchProps 23 | 24 | const Image: FunctionComponent = ({ 25 | src, 26 | alt, 27 | draggable, 28 | valid, 29 | loadImage, 30 | }) => { 31 | useEffect(() => { 32 | if (valid === undefined) loadImage(src) 33 | }, [src]) 34 | 35 | return {alt} 36 | } 37 | 38 | const mapStateToProps: MapStateToProps< 39 | IConnectedProps, 40 | IPassedProps, 41 | IState 42 | > = (state, props) => ({ 43 | valid: state.images[props.src], 44 | }) 45 | 46 | const mapDispatchToProps: IDispatchProps = { 47 | loadImage: loadImageFn, 48 | } 49 | 50 | const ConnectedImage = connect(mapStateToProps, mapDispatchToProps)(Image) 51 | export { ConnectedImage as Image } 52 | -------------------------------------------------------------------------------- /client/src/ts/components/Navbar/NavbarItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | Dispatch, 3 | FunctionComponent, 4 | MouseEvent, 5 | SetStateAction, 6 | } from 'react' 7 | import { Link } from 'react-router-dom' 8 | import { ExtLink } from '../ExtLink' 9 | 10 | interface IItemProps { 11 | to: string 12 | 13 | onClick?: (e: MouseEvent) => any 14 | setActive: Dispatch> 15 | } 16 | 17 | export const NavbarItem: FunctionComponent = ({ 18 | to, 19 | children, 20 | 21 | onClick, 22 | setActive, 23 | }) => ( 24 | { 28 | setActive(false) 29 | if (typeof onClick === 'function') onClick(e) 30 | }} 31 | > 32 | {children} 33 | 34 | ) 35 | 36 | interface IItemExtProps { 37 | href: string 38 | } 39 | 40 | export const NavbarItemExt: FunctionComponent = ({ 41 | href, 42 | children, 43 | }) => ( 44 | 45 | {children} 46 | 47 | ) 48 | 49 | interface IClickableItemProps { 50 | onClick: (e: MouseEvent) => any 51 | } 52 | 53 | export const NavbarClickableItem: FunctionComponent = ({ 54 | onClick, 55 | children, 56 | }) => ( 57 | onClick(e)}> 58 | {children} 59 | 60 | ) 61 | -------------------------------------------------------------------------------- /server/src/routes/users.ts: -------------------------------------------------------------------------------- 1 | import cors from '@koa/cors' 2 | import passport from 'koa-passport' 3 | import Router from 'koa-router' 4 | import { rateLimit } from '~middleware' 5 | import { IRedactedUser, IUserModel, User } from '~mongo/models' 6 | 7 | const router = new Router({ 8 | prefix: '/users', 9 | }).use(cors()) 10 | 11 | router.get( 12 | '/me', 13 | rateLimit(10 * 1000, 50), 14 | passport.authenticate('jwt', { session: false }), 15 | async ctx => { 16 | const user: IUserModel = ctx.state.user 17 | 18 | interface IUserID { 19 | _id: string 20 | } 21 | 22 | const userInfo: IRedactedUser & IUserID = { 23 | _id: user._id, 24 | admin: user.admin, 25 | links: user.links, 26 | username: user.username, 27 | verified: user.verified, 28 | } 29 | 30 | return (ctx.body = userInfo) 31 | } 32 | ) 33 | 34 | router.get( 35 | '/find/:id', 36 | rateLimit({ 37 | duration: 20 * 1000, 38 | id: ctx => `/users/find:${ctx.realIP}`, 39 | max: 10, 40 | }), 41 | async ctx => { 42 | try { 43 | const user = await User.findById(ctx.params.id, '-password -email') 44 | if (!user) return (ctx.status = 404) 45 | 46 | return (ctx.body = user) 47 | } catch (err) { 48 | if (err.name === 'CastError') return (ctx.status = 404) 49 | else throw err 50 | } 51 | } 52 | ) 53 | 54 | export { router as usersRouter } 55 | -------------------------------------------------------------------------------- /client/src/ts/store/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | connectRouter, 3 | routerMiddleware, 4 | RouterState, 5 | } from 'connected-react-router' 6 | import { History } from 'history' 7 | import { 8 | applyMiddleware, 9 | combineReducers, 10 | compose, 11 | createStore as createReduxStore, 12 | } from 'redux' 13 | import { composeWithDevTools } from 'redux-devtools-extension' 14 | import thunk from 'redux-thunk' 15 | 16 | import { audioReducer, IAudioState } from './audio' 17 | import { IImagesState, imagesReducer } from './images' 18 | import { IScrollersState, scrollersReducer } from './scrollers' 19 | import { IUserState, userReducer } from './user' 20 | 21 | export interface IState { 22 | audio: IAudioState 23 | images: IImagesState 24 | router: RouterState 25 | scrollers: IScrollersState 26 | user: IUserState 27 | } 28 | 29 | const createRootReducer = (hist: History) => 30 | combineReducers({ 31 | audio: audioReducer, 32 | images: imagesReducer, 33 | router: connectRouter(hist), 34 | scrollers: scrollersReducer, 35 | user: userReducer, 36 | }) 37 | 38 | const composeEnhancers: typeof compose = 39 | process.env.NODE_ENV === 'production' 40 | ? compose 41 | : (composeWithDevTools as typeof compose) 42 | 43 | export const createStore = (hist: History) => 44 | createReduxStore( 45 | createRootReducer(hist), 46 | composeEnhancers(applyMiddleware(thunk, routerMiddleware(hist))) 47 | ) 48 | -------------------------------------------------------------------------------- /server/src/constants.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | 3 | export const CDN_PATH = join(__dirname, '..', 'cdn') 4 | export const DUMP_PATH = join(CDN_PATH, 'dumps') 5 | export const LEGAL_PATH = join(__dirname, '..', 'legal') 6 | export const BEATSAVER_EPOCH = 1525132800 7 | 8 | export const FILE_EXT_WHITELIST = [ 9 | '.dat', 10 | '.json', 11 | '.egg', 12 | '.ogg', 13 | '.png', 14 | '.jpg', 15 | '.jpeg', 16 | '.srt', 17 | ] 18 | 19 | export const FILE_TYPE_BLACKLIST = [ 20 | 'application/gzip', 21 | 'application/vnd.ms-cab-compressed', 22 | 'application/wasm', 23 | 'application/x-7z-compressed', 24 | 'application/x-apple-diskimage', 25 | 'application/x-bzip2', 26 | 'application/x-compress', 27 | 'application/x-deb', 28 | 'application/x-google-chrome-extension', 29 | 'application/x-lzip', 30 | 'application/x-msdownload', 31 | 'application/x-msi', 32 | 'application/x-rar-compressed', 33 | 'application/x-rpm', 34 | 'application/x-shockwave-flash', 35 | 'application/x-sqlite3', 36 | 'application/x-tar', 37 | 'application/x-unix-archive', 38 | 'application/x-xz', 39 | 'application/x.ms.shortcut', 40 | 'application/zip', 41 | 'text/calendar', 42 | ] 43 | 44 | const SCHEMA_BASE_URI = 45 | 'https://raw.githubusercontent.com/lolPants/beatmap-schemas/master/schemas' 46 | 47 | export const SCHEMA_INFO = `${SCHEMA_BASE_URI}/info.schema.json` 48 | export const SCHEMA_DIFFICULTY = `${SCHEMA_BASE_URI}/difficulty.schema.json` 49 | -------------------------------------------------------------------------------- /server/src/utils/schemas.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { ValidateFunction } from 'ajv' 2 | import axios from './axios' 3 | import signale from './signale' 4 | 5 | let loaded = false 6 | const ajv = new Ajv() 7 | const cache: Map = new Map() 8 | 9 | const loadSchema: (url: string) => Promise = async url => { 10 | const resp = await axios.get(url) 11 | if (typeof resp.data !== 'object') { 12 | throw new Error('Schema response is not an object') 13 | } 14 | 15 | cache.set(url, resp.data) 16 | } 17 | 18 | const initialSync: () => Promise = async () => { 19 | if (loaded === true) return 20 | loaded = true 21 | 22 | await sync() 23 | } 24 | 25 | export const sync: () => Promise = async () => { 26 | signale.start('Syncing schemas...') 27 | 28 | const keys = [...cache.keys()] 29 | await Promise.all(keys.map(x => loadSchema(x))) 30 | 31 | signale.complete('Schema sync complete!') 32 | } 33 | 34 | export const compile: ( 35 | url: string 36 | ) => Promise = async url => { 37 | await initialSync() 38 | if (cache.has(url) === false) { 39 | throw new Error('Schema URL is not registered in this store') 40 | } 41 | 42 | const schema = cache.get(url) 43 | ajv.removeSchema(url) 44 | 45 | return ajv.compile(schema) 46 | } 47 | 48 | export const register: (url: string) => Promise = async url => { 49 | await loadSchema(url) 50 | } 51 | 52 | export const deregister: (url: string) => void = url => { 53 | cache.delete(url) 54 | } 55 | -------------------------------------------------------------------------------- /client/src/ts/routes/License.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { ExtLink } from '../components/ExtLink' 3 | import { TextPage } from '../components/TextPage' 4 | 5 | export const License: FunctionComponent = () => ( 6 | 7 |

    8 | BeatSaver is licensed under the{' '} 9 | 10 | ISC License 11 | 12 |

    13 |
    14 | 15 |
    16 |

    Copyright © 2019, Jack Baron

    17 | 18 |

    19 | Permission to use, copy, modify, and/or distribute this software for any 20 | purpose with or without fee is hereby granted, provided that the above 21 | copyright notice and this permission notice appear in all copies. 22 |

    23 | 24 |

    25 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 26 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 27 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 28 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 29 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 30 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 31 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 32 |

    33 |
    34 |
    35 | ) 36 | -------------------------------------------------------------------------------- /client/src/sass/_loader.scss: -------------------------------------------------------------------------------- 1 | // Loading.io double-ring spinner 2 | // https://loading.io/spinner/double-ring 3 | // CC BY License 4 | // CSS Class names have been modified 5 | 6 | @keyframes spinner { 7 | 0% { 8 | -webkit-transform: rotate(0); 9 | transform: rotate(0); 10 | } 11 | 12 | 100% { 13 | -webkit-transform: rotate(360deg); 14 | transform: rotate(360deg); 15 | } 16 | } 17 | 18 | @keyframes spinner_reverse { 19 | 0% { 20 | -webkit-transform: rotate(0); 21 | transform: rotate(0); 22 | } 23 | 24 | 100% { 25 | -webkit-transform: rotate(-360deg); 26 | transform: rotate(-360deg); 27 | } 28 | } 29 | 30 | .loader-spinner { 31 | position: relative; 32 | 33 | & div { 34 | position: absolute; 35 | width: 180px; 36 | height: 180px; 37 | top: 10px; 38 | left: 10px; 39 | border-radius: 50%; 40 | border: 10px solid #000; 41 | border-color: #b8b9c2 transparent #b8b9c2 transparent; 42 | animation: spinner 1.7s linear infinite; 43 | 44 | &:nth-child(2) { 45 | width: 156px; 46 | height: 156px; 47 | top: 22px; 48 | left: 22px; 49 | border-color: transparent #9292a0 transparent #9292a0; 50 | animation: spinner_reverse 1.7s linear infinite; 51 | } 52 | } 53 | 54 | width: 120px !important; 55 | height: 120px !important; 56 | transform: translate(-60px, -60px) scale(0.4) translate(60px, 60px); 57 | } 58 | 59 | .loader-container { 60 | display: flex; 61 | justify-content: center; 62 | align-items: center; 63 | } 64 | -------------------------------------------------------------------------------- /server/src/utils/fs.ts: -------------------------------------------------------------------------------- 1 | import fs, { PathLike, Stats } from 'fs' 2 | import globby from 'globby' 3 | import mkdirp from 'mkdirp' 4 | import { join } from 'path' 5 | import rimraf from 'rimraf' 6 | import { promisify } from 'util' 7 | 8 | const mkdirpPromise = promisify(mkdirp) 9 | export { mkdirpPromise as mkdirp } 10 | 11 | const rimrafPromise = promisify(rimraf) 12 | export { rimrafPromise as rimraf } 13 | 14 | export const access = promisify(fs.access) 15 | export const readFile = promisify(fs.readFile) 16 | export const write = promisify(fs.write) 17 | export const writeFile = promisify(fs.writeFile) 18 | export const stat = promisify(fs.stat) 19 | export const rename = promisify(fs.rename) 20 | 21 | export const exists = async (path: PathLike) => { 22 | try { 23 | await access(path, fs.constants.F_OK) 24 | return true 25 | } catch (err) { 26 | if (err.code === 'ENOENT') return false 27 | else throw err 28 | } 29 | } 30 | 31 | interface IGlobStats extends Stats { 32 | path: string 33 | absolute: string 34 | depth: number 35 | } 36 | 37 | export const globStats: ( 38 | patterns: string | readonly string[], 39 | options?: globby.GlobbyOptions 40 | ) => Promise = async (patterns, options) => { 41 | const opts: globby.GlobbyOptions = { ...options, stats: true } 42 | 43 | const globs: unknown[] = await globby(patterns, opts) 44 | globs.forEach((x: any) => { 45 | x.absolute = opts.cwd !== undefined ? join(opts.cwd, x.path) : x.path 46 | return x 47 | }) 48 | 49 | return globs as IGlobStats[] 50 | } 51 | -------------------------------------------------------------------------------- /server/src/mongo/models/User.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema } from 'mongoose' 2 | import { withoutKeys } from '~mongo/plugins' 3 | 4 | export interface IRedactedUser { 5 | username: string 6 | verified: boolean 7 | 8 | admin: boolean 9 | 10 | links: { 11 | steam?: string 12 | oculus?: string 13 | } 14 | } 15 | 16 | export interface IUserLean extends IRedactedUser { 17 | email: string 18 | password: string 19 | 20 | verifyToken: string | null 21 | } 22 | 23 | export type IUserModel = IUserLean & Document 24 | 25 | const schema: Schema = new Schema({ 26 | email: { 27 | index: true, 28 | lowercase: true, 29 | match: /\S+@\S+\.\S+/, 30 | required: true, 31 | type: String, 32 | unique: true, 33 | }, 34 | password: { type: String, required: true, maxlength: 72 }, 35 | username: { 36 | es_indexed: true, 37 | lowercase: true, 38 | maxlength: 24, 39 | required: true, 40 | type: String, 41 | unique: true, 42 | }, 43 | 44 | verified: { type: Boolean, default: false }, 45 | verifyToken: { type: String, default: null }, 46 | 47 | links: { 48 | oculus: { type: String, default: undefined, maxlength: 16 }, 49 | steam: { type: String, default: undefined, maxlength: 17 }, 50 | }, 51 | 52 | admin: { type: Boolean, default: false }, 53 | }) 54 | 55 | schema.plugin( 56 | withoutKeys( 57 | ['__v', 'email', 'password', 'verified', 'verifyToken', 'admin'], 58 | true 59 | ) 60 | ) 61 | 62 | export const User = mongoose.model('user', schema) 63 | export { schema as userSchema } 64 | -------------------------------------------------------------------------------- /client/ssr/index.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | import Router from 'koa-router' 3 | import send from 'koa-send' 4 | import koaStatic from 'koa-static' 5 | import mongoose from 'mongoose' 6 | import { parse } from 'path' 7 | import { MONGO_URL, PORT } from './env' 8 | import { htmlPath, root } from './generateHTML' 9 | import { middleware } from './middleware' 10 | import signale, { panic } from './signale' 11 | 12 | const app = new Koa() 13 | const router = new Router() 14 | 15 | app.use( 16 | koaStatic(root, { 17 | setHeaders: (req, path) => { 18 | const { ext } = parse(path) 19 | const allowed = [ 20 | '.css', 21 | '.js', 22 | '.map', 23 | '.png', 24 | '.jpg', 25 | '.jpeg', 26 | '.svg', 27 | '.gif', 28 | ] 29 | 30 | if (allowed.includes(ext)) { 31 | req.setHeader('Cache-Control', 'public, max-age=31536000') 32 | } 33 | }, 34 | }) 35 | ) 36 | 37 | router.get('/beatmap/:key', middleware) 38 | router.get('*', async ctx => send(ctx, htmlPath, { root: '/', maxAge: -1 })) 39 | 40 | mongoose 41 | .connect(MONGO_URL, { 42 | useCreateIndex: true, 43 | useNewUrlParser: true, 44 | useUnifiedTopology: true, 45 | }) 46 | .then(() => { 47 | app 48 | .use(router.routes()) 49 | .use(router.allowedMethods()) 50 | .listen(PORT) 51 | .on('listening', () => { 52 | signale.start(`Listening over HTTP on port ${PORT}`) 53 | }) 54 | }) 55 | .catch(err => { 56 | signale.fatal(`Failed to connect to MongoDB!`) 57 | return panic(err) 58 | }) 59 | -------------------------------------------------------------------------------- /server/src/mongo/plugins/paginate.ts: -------------------------------------------------------------------------------- 1 | import { Document, PaginateModel, PaginateOptions } from 'mongoose' 2 | import paginate from 'mongoose-paginate-v2' 3 | import { RESULTS_PER_PAGE } from '~environment' 4 | 5 | const pOptions: PaginateOptions = { 6 | limit: RESULTS_PER_PAGE, 7 | } 8 | 9 | interface IPaginateOptions extends PaginateOptions { 10 | populate: string 11 | projection: string 12 | } 13 | 14 | interface IPaginateResult { 15 | docs: T[] 16 | totalDocs: number 17 | 18 | lastPage: number 19 | prevPage: number | null 20 | nextPage: number | null 21 | } 22 | 23 | const paginateFn: >( 24 | model: M, 25 | query?: object, 26 | options?: Partial 27 | ) => Promise> = async (model, query, options) => { 28 | const opts: PaginateOptions = options || {} 29 | const page = opts.page !== undefined ? opts.page + 1 : undefined 30 | 31 | const { 32 | docs, 33 | totalDocs: td, 34 | totalPages: tp, 35 | prevPage: p, 36 | nextPage: n, 37 | } = await model.paginate(query, { ...opts, page }) 38 | 39 | const totalDocs = td as number 40 | const totalPages = tp as number 41 | const prev = p as number | null 42 | const next = n as number | null 43 | 44 | const lastPage = totalPages - 1 45 | const prevPage = prev === null ? null : prev - 1 46 | const nextPage = next === null ? null : next - 1 47 | 48 | return { docs, totalDocs, lastPage, prevPage, nextPage } 49 | } 50 | 51 | export { paginateFn as paginate } 52 | 53 | // @ts-ignore 54 | paginate.paginate.options = pOptions 55 | -------------------------------------------------------------------------------- /client/ssr/middleware.tsx: -------------------------------------------------------------------------------- 1 | import { Middleware } from 'koa' 2 | import send from 'koa-send' 3 | import mongoose from 'mongoose' 4 | import { generateOpenGraph, html, htmlPath } from './generateHTML' 5 | 6 | const oldKeyRX = /^\d+-(\d+)$/ 7 | const newKeyRX = /^[0-9a-f]+$/ 8 | 9 | export const parseKey: (key: string) => string | false = key => { 10 | if (typeof key !== 'string') return false 11 | 12 | const isOld = key.match(oldKeyRX) 13 | if (isOld !== null) { 14 | const oldKey = isOld[1] 15 | return parseInt(oldKey, 10).toString(16) 16 | } 17 | 18 | const isNew = key.match(newKeyRX) 19 | if (isNew === null) return false 20 | 21 | const k = key.toLowerCase() 22 | return k 23 | } 24 | 25 | export const middleware: Middleware = async ctx => { 26 | ctx.set('cache-control', 'max-age=0') 27 | const notFound = () => send(ctx, htmlPath, { root: '/', maxAge: -1 }) 28 | 29 | const key = parseKey(ctx.params.key) 30 | if (!key) return notFound() 31 | 32 | const numKey = await parseInt(key, 16) 33 | const beatmaps = await mongoose.connection.db.collection('beatmaps') 34 | const [map] = await (await beatmaps.find( 35 | { key: numKey }, 36 | { projection: { votes: 0 } } 37 | )).toArray() 38 | 39 | if (!map) return notFound() 40 | const metaTags = generateOpenGraph({ 41 | description: map.description, 42 | image: `${ctx.request.origin}/cdn/${key}/${map.hash}${map.coverExt}`, 43 | siteName: 'BeatSaver', 44 | title: map.name, 45 | url: `${ctx.request.origin}${ctx.originalUrl}`, 46 | }) 47 | 48 | const page = html.replace('', `${metaTags}`) 49 | return (ctx.body = page) 50 | } 51 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # This is given as a sample, you should probably create your own in production 2 | version: '3' 3 | 4 | services: 5 | mongo: 6 | image: mongo:4 7 | restart: always 8 | networks: 9 | - internal 10 | volumes: 11 | - mongo:/data/db:rw 12 | 13 | redis: 14 | image: redis:4-alpine 15 | restart: 'always' 16 | networks: 17 | - internal 18 | 19 | elastic: 20 | image: docker.elastic.co/elasticsearch/elasticsearch:7.1.1 21 | restart: always 22 | networks: 23 | - internal 24 | environment: 25 | - discovery.type=single-node 26 | - bootstrap.memory_lock=true 27 | - "ES_JAVA_OPTS=-Xms512m -Xmx512m" 28 | ulimits: 29 | memlock: 30 | soft: -1 31 | hard: -1 32 | volumes: 33 | - esdata:/usr/share/elasticsearch/data:rw 34 | 35 | client: 36 | image: lolpants/beatsaver-reloaded:client-latest 37 | build: client 38 | restart: always 39 | networks: 40 | - internal 41 | ports: 42 | - 8000:1234 43 | 44 | server: 45 | image: lolpants/beatsaver-reloaded:server-latest 46 | build: server 47 | restart: always 48 | depends_on: 49 | - mongo 50 | - elastic 51 | - redis 52 | networks: 53 | - internal 54 | volumes: 55 | - cdn:/home/node/app/cdn:rw 56 | env_file: 57 | - .env 58 | ports: 59 | - 3000:3000 60 | 61 | docs: 62 | image: lolpants/beatsaver-reloaded:docs-latest 63 | build: docs 64 | restart: always 65 | ports: 66 | - 5000:80 67 | 68 | networks: 69 | internal: 70 | 71 | volumes: 72 | cdn: 73 | mongo: 74 | esdata: 75 | -------------------------------------------------------------------------------- /client/src/ts/store/user/actions.ts: -------------------------------------------------------------------------------- 1 | import { axios } from '../../utils/axios' 2 | import { IUser, UserActionTypes } from './types' 3 | 4 | type TypedThunk

    = Thunk 5 | 6 | export type CheckUser = ThunkFunction 7 | export const checkUser: () => TypedThunk = () => async dispatch => { 8 | try { 9 | const user = await axios.get('/users/me') 10 | 11 | dispatch({ 12 | payload: user.data, 13 | type: UserActionTypes.SET_USER, 14 | }) 15 | } catch (err) { 16 | dispatch({ 17 | payload: null, 18 | type: UserActionTypes.SET_USER, 19 | }) 20 | } 21 | } 22 | 23 | export type Login = ThunkFunction 24 | export const login: ( 25 | username: string, 26 | password: string 27 | ) => TypedThunk = (username, password) => async ( 28 | dispatch, 29 | getState 30 | ) => { 31 | await axios.post('/auth/login', { username, password }) 32 | await checkUser()(dispatch, getState) 33 | } 34 | 35 | export type Register = ThunkFunction 36 | export const register: ( 37 | username: string, 38 | email: string, 39 | password: string 40 | ) => TypedThunk = (username, email, password) => async ( 41 | dispatch, 42 | getState 43 | ) => { 44 | await axios.post('/auth/register', { username, email, password }) 45 | await checkUser()(dispatch, getState) 46 | } 47 | 48 | export type Logout = ThunkFunction 49 | export const logout: () => TypedThunk = () => dispatch => { 50 | localStorage.removeItem('token') 51 | 52 | dispatch({ 53 | payload: null, 54 | type: UserActionTypes.SET_USER, 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/server.yml: -------------------------------------------------------------------------------- 1 | name: Server CI 2 | on: 3 | push: 4 | paths: 5 | - 'server/*' 6 | - 'server/**/*' 7 | - '.github/workflows/server.yml' 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12.x 18 | - name: Install and test 19 | run: | 20 | yarn install --frozen-lockfile 21 | yarn test 22 | working-directory: ./server 23 | - name: Annotate TSLint 24 | if: failure() 25 | uses: mooyoul/tslint-actions@v1.1.1 26 | with: 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | pattern: 'server/src/**/*.ts' 29 | config: 'server/tslint.json' 30 | build: 31 | runs-on: ubuntu-latest 32 | needs: test 33 | steps: 34 | - uses: actions/checkout@v1 35 | - name: Build Docker image 36 | run: | 37 | DOCKER_REPO=`echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]'`; 38 | DOCKER_TAG=`git rev-parse --short HEAD`; 39 | docker build . --file Dockerfile --build-arg VCS_REF="$DOCKER_TAG" --tag "docker.pkg.github.com/$DOCKER_REPO/server:$DOCKER_TAG" 40 | working-directory: ./server 41 | - name: Login to Docker Registry 42 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login docker.pkg.github.com --username lolpants --password-stdin 43 | - name: Push to Docker Registry 44 | run: | 45 | DOCKER_REPO=`echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]'`; 46 | docker push "docker.pkg.github.com/$DOCKER_REPO/server" 47 | if: github.ref == 'refs/heads/master' 48 | -------------------------------------------------------------------------------- /client/src/ts/remote/beatmap.d.ts: -------------------------------------------------------------------------------- 1 | declare interface IBeatmap { 2 | _id: string 3 | key: string 4 | name: string 5 | description: string 6 | 7 | uploader: { 8 | _id: string 9 | username: string 10 | } 11 | 12 | uploaded: Date | string 13 | 14 | metadata: { 15 | songName: string 16 | songSubName: string 17 | songAuthorName: string 18 | levelAuthorName: string 19 | 20 | bpm: number 21 | duration?: number 22 | 23 | difficulties: { 24 | easy: boolean 25 | normal: boolean 26 | hard: boolean 27 | expert: boolean 28 | expertPlus: boolean 29 | } 30 | 31 | characteristics: IBeatmapCharacteristic[] 32 | } 33 | 34 | stats: IStats 35 | 36 | downloadURL: string 37 | directDownload: string 38 | coverURL: string 39 | 40 | hash: string 41 | } 42 | 43 | declare interface IStats { 44 | downloads: number 45 | plays: number 46 | 47 | upVotes: number 48 | downVotes: number 49 | 50 | rating: number 51 | heat: number 52 | } 53 | 54 | declare interface IBeatmapCharacteristic { 55 | name: string 56 | difficulties: { 57 | easy: IDifficulty | null 58 | normal: IDifficulty | null 59 | hard: IDifficulty | null 60 | expert: IDifficulty | null 61 | expertPlus: IDifficulty | null 62 | } 63 | } 64 | 65 | declare interface IDifficulty { 66 | duration: number 67 | length: number 68 | bombs: number 69 | notes: number 70 | obstacles: number 71 | njs: number 72 | } 73 | 74 | declare interface IMapStats { 75 | _id: IBeatmap['_id'] 76 | key: IBeatmap['key'] 77 | hash: IBeatmap['hash'] 78 | 79 | stats: IStats 80 | } 81 | 82 | declare type IBeatmapResponse = IResponse 83 | -------------------------------------------------------------------------------- /.github/workflows/client.yml: -------------------------------------------------------------------------------- 1 | name: Client CI 2 | on: 3 | push: 4 | paths: 5 | - 'client/*' 6 | - 'client/**/*' 7 | - '.github/workflows/client.yml' 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12.x 18 | - name: Install and test 19 | run: | 20 | yarn install --frozen-lockfile 21 | yarn test 22 | working-directory: ./client 23 | - name: Annotate TSLint 24 | if: failure() 25 | uses: mooyoul/tslint-actions@v1.1.1 26 | with: 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | pattern: 'client/src/**/*.{ts,tsx}' 29 | config: 'client/tslint.json' 30 | build: 31 | runs-on: ubuntu-latest 32 | needs: test 33 | steps: 34 | - uses: actions/checkout@v1 35 | - name: Build Docker image 36 | run: | 37 | DOCKER_REPO=`echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]'`; 38 | DOCKER_TAG=`git rev-parse --short HEAD`; 39 | docker build . --file Dockerfile --build-arg VCS_REF="$DOCKER_TAG" --tag "docker.pkg.github.com/$DOCKER_REPO/client:$DOCKER_TAG" 40 | working-directory: ./client 41 | - name: Login to Docker Registry 42 | run: echo ${{ secrets.GITHUB_TOKEN }} | docker login docker.pkg.github.com --username lolpants --password-stdin 43 | - name: Push to Docker Registry 44 | run: | 45 | DOCKER_REPO=`echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]'`; 46 | docker push "docker.pkg.github.com/$DOCKER_REPO/client" 47 | if: github.ref == 'refs/heads/master' 48 | -------------------------------------------------------------------------------- /client/src/ts/components/Boundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo } from 'react' 2 | import { TextPage } from './TextPage' 3 | 4 | interface IState { 5 | hasError: boolean 6 | error: Error | undefined 7 | info: ErrorInfo | undefined 8 | } 9 | 10 | export class Boundary extends Component<{}, IState> { 11 | public static getDerivedStateFromError(error: Error) { 12 | return { hasError: true, error } 13 | } 14 | 15 | private errorListener: (ev: ErrorEvent) => void 16 | 17 | constructor(props: any) { 18 | super(props) 19 | 20 | this.state = { 21 | error: undefined, 22 | hasError: false, 23 | info: undefined, 24 | } 25 | 26 | this.errorListener = ev => { 27 | this.setState({ hasError: true, error: ev.error }) 28 | return false 29 | } 30 | } 31 | 32 | public componentDidMount() { 33 | window.addEventListener('error', this.errorListener) 34 | } 35 | 36 | public componentWillUnmount() { 37 | window.removeEventListener('error', this.errorListener) 38 | } 39 | 40 | public componentDidCatch(error: Error, info: ErrorInfo) { 41 | this.setState({ error, info }) 42 | } 43 | 44 | public render() { 45 | if (this.state.hasError) { 46 | return ( 47 | 48 | {this.state.error === undefined ? null : ( 49 |

    52 | Stack Trace 53 |
    {this.state.error.stack}
    54 |
    55 | )} 56 | 57 | ) 58 | } 59 | 60 | return this.props.children 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/src/ts/components/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import React, { ChangeEvent, FunctionComponent } from 'react' 3 | 4 | interface IProps { 5 | label: string 6 | errorLabel?: string 7 | file: File | null 8 | accept?: string 9 | 10 | onChange: (e: File | null) => any 11 | } 12 | 13 | export const FileInput: FunctionComponent = ({ 14 | label, 15 | errorLabel, 16 | file, 17 | accept, 18 | onChange, 19 | }) => { 20 | const handleChange = (e: ChangeEvent) => { 21 | if (typeof onChange !== 'function') return 22 | if (e.target.files === null || e.target.files[0] === undefined) { 23 | onChange(null) 24 | return 25 | } 26 | 27 | onChange(e.target.files[0]) 28 | } 29 | 30 | return ( 31 |
    32 | 38 | 39 |
    40 | 60 |
    61 |
    62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'source-map-support/register' 2 | 3 | import mongoose from 'mongoose' 4 | import { SCHEMA_DIFFICULTY, SCHEMA_INFO } from '~constants' 5 | import { 6 | ELASTIC_DISABLED, 7 | ELASTIC_HOST, 8 | ELASTIC_PORT, 9 | IS_DEV, 10 | MONGO_URL, 11 | PORT, 12 | } from '~environment' 13 | import { awaitCacheDB, awaitRateLimitDB } from '~redis' 14 | import '~strategies' 15 | import axios from '~utils/axios' 16 | import * as schemas from '~utils/schemas' 17 | import signale, { panic } from '~utils/signale' 18 | import { app } from './koa' 19 | import './tasks' 20 | 21 | if (IS_DEV) signale.warn('Running in development environment!') 22 | 23 | mongoose 24 | .connect(MONGO_URL, { 25 | useCreateIndex: true, 26 | useNewUrlParser: true, 27 | useUnifiedTopology: true, 28 | }) 29 | .then(() => { 30 | signale.info(`Connected to MongoDB ${IS_DEV ? 'Instance' : 'Cluster'}`) 31 | return Promise.all([awaitCacheDB(), awaitRateLimitDB()]) 32 | }) 33 | .then(() => { 34 | try { 35 | if (!ELASTIC_DISABLED) { 36 | axios.get(`http://${ELASTIC_HOST}:${ELASTIC_PORT}`) 37 | signale.info('Connected to Elasticsearch instance!') 38 | } 39 | } catch (err) { 40 | signale.fatal('Failed to connect to Elasticsearch!') 41 | process.exit(1) 42 | } 43 | }) 44 | .then(() => 45 | Promise.all([ 46 | schemas.register(SCHEMA_INFO), 47 | schemas.register(SCHEMA_DIFFICULTY), 48 | ]) 49 | ) 50 | .then(() => { 51 | app.listen(PORT).on('listening', () => { 52 | signale.start(`Listening over HTTP on port ${PORT}`) 53 | }) 54 | }) 55 | .catch(err => { 56 | signale.fatal( 57 | `Failed to connect to MongoDB ${IS_DEV ? 'Instance' : 'Cluster'}!` 58 | ) 59 | 60 | return panic(err) 61 | }) 62 | -------------------------------------------------------------------------------- /client/src/ts/Routes.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react' 2 | import { Route, Switch } from 'react-router-dom' 3 | 4 | import { DMCAPage, PrivacyPage } from './components/LegalPage' 5 | import { Beatmap } from './routes/Beatmap' 6 | import { Downloads, Hot, Latest, Plays, Rating } from './routes/Browse' 7 | import { Index } from './routes/Index' 8 | import { Legacy } from './routes/Legacy' 9 | import { License } from './routes/License' 10 | import { Login } from './routes/Login' 11 | import { NotFound } from './routes/NotFound' 12 | import { Register } from './routes/Register' 13 | import { Search } from './routes/Search' 14 | import { Upload } from './routes/Upload' 15 | import { Uploader } from './routes/Uploader' 16 | 17 | export const Routes: FunctionComponent = () => ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ) 42 | -------------------------------------------------------------------------------- /client/src/ts/store/audio/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux' 2 | import { AudioActionTypes, IAudioState } from './types' 3 | 4 | const initialState: IAudioState = { 5 | source: undefined, 6 | state: 'stopped', 7 | volume: 0.5, 8 | 9 | error: null, 10 | key: null, 11 | loading: false, 12 | 13 | urls: {}, 14 | } 15 | 16 | const audio = new Audio() 17 | audio.volume = initialState.volume 18 | 19 | export const audioReducer: Reducer< 20 | IAudioState, 21 | IAnyAction 22 | > = (state = initialState, action) => { 23 | if (action.type === AudioActionTypes.PLAY) { 24 | audio.play() 25 | return { ...state, state: 'playing' } 26 | } else if (action.type === AudioActionTypes.PAUSE) { 27 | audio.pause() 28 | return { ...state, state: 'paused' } 29 | } else if (action.type === AudioActionTypes.STOP) { 30 | audio.pause() 31 | audio.currentTime = 0 32 | 33 | return { ...state, state: 'stopped', key: null, source: undefined } 34 | } else if (action.type === AudioActionTypes.SET_VOLUME) { 35 | audio.volume = action.payload 36 | return { ...state, volume: action.payload } 37 | } else if (action.type === AudioActionTypes.SET_SOURCE) { 38 | audio.src = action.payload 39 | return { ...state, source: action.payload } 40 | } else if (action.type === AudioActionTypes.SET_LOADING) { 41 | return { ...state, loading: action.payload } 42 | } else if (action.type === AudioActionTypes.SET_ERROR) { 43 | return { ...state, error: action.payload } 44 | } else if (action.type === AudioActionTypes.SET_PLAYING_KEY) { 45 | return { ...state, key: action.payload } 46 | } else if (action.type === AudioActionTypes.ADD_AUDIO_URL) { 47 | return { 48 | ...state, 49 | urls: { ...action.payload, [action.payload.key]: action.payload.value }, 50 | } 51 | } else { 52 | return state 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/src/ts/routes/Search.tsx: -------------------------------------------------------------------------------- 1 | import { replace as replaceFn } from 'connected-react-router' 2 | import { parse, stringify } from 'query-string' 3 | import React, { FunctionComponent, useEffect, useState } from 'react' 4 | import { connect, MapStateToProps } from 'react-redux' 5 | import { BeatmapList } from '../components/Beatmap' 6 | import { IconInput } from '../components/Input' 7 | import { IState } from '../store' 8 | 9 | interface IProps { 10 | pathname: string 11 | queryStr: string 12 | 13 | replace: typeof replaceFn 14 | } 15 | 16 | const Search: FunctionComponent = ({ pathname, queryStr, replace }) => { 17 | const [query, setQuery] = useState('') 18 | 19 | useEffect(() => { 20 | const { q } = parse(queryStr) 21 | if (q !== query) { 22 | setQuery('') 23 | window.scroll({ top: 0 }) 24 | } 25 | }, [queryStr]) 26 | 27 | useEffect(() => { 28 | const { q } = parse(queryStr) 29 | if (q && typeof q === 'string') setQuery(q) 30 | }, [pathname]) 31 | 32 | const search = (q: string) => { 33 | setQuery(q) 34 | 35 | if (!q) replace({ search: '' }) 36 | else replace({ search: stringify({ q }) }) 37 | } 38 | 39 | return ( 40 | <> 41 |
    42 | search(v)} 45 | placeholder='Search BeatSaver' 46 | iconClass='fas fa-search' 47 | /> 48 |
    49 | 50 | 51 | 52 | ) 53 | } 54 | 55 | const mapStateToProps: MapStateToProps = state => ({ 56 | pathname: state.router.location.pathname, 57 | queryStr: state.router.location.search, 58 | 59 | replace: replaceFn, 60 | }) 61 | 62 | const ConnectedSearch = connect(mapStateToProps, { replace: replaceFn })(Search) 63 | export { ConnectedSearch as Search } 64 | -------------------------------------------------------------------------------- /client/src/ts/utils/scroll.ts: -------------------------------------------------------------------------------- 1 | import { replace } from 'connected-react-router' 2 | import debounceFn from 'debounce-fn' 3 | import { history, store } from '../init' 4 | 5 | export const resetScroll = () => { 6 | const layout = document.getElementById('layout') 7 | const parent = layout && layout.parentElement 8 | if (!parent) return 9 | 10 | parent.scrollTo(0, 0) 11 | } 12 | 13 | export const checkHash = () => { 14 | const layout = document.getElementById('layout') 15 | const parent = layout && layout.parentElement 16 | if (!parent) return 17 | 18 | const { 19 | action, 20 | location: { hash }, 21 | } = history 22 | 23 | if (action !== 'POP' || !hash) return 24 | 25 | const id = hash.replace('#', '') 26 | const elem = document.getElementById(id) 27 | if (!elem) return 28 | 29 | elem.scrollIntoView(true) 30 | parent.scrollBy(0, -17) 31 | } 32 | 33 | history.listen(() => setTimeout(() => checkHash(), 0)) 34 | 35 | const inView = (elem: Element) => { 36 | const bounding = elem.getBoundingClientRect() 37 | return ( 38 | bounding.top >= 0 && 39 | bounding.left >= 0 && 40 | bounding.bottom <= 41 | (window.innerHeight || document.documentElement.clientHeight) && 42 | bounding.right <= 43 | (window.innerWidth || document.documentElement.clientWidth) 44 | ) 45 | } 46 | 47 | const initScrollHandler = () => { 48 | const root = document.getElementById('root') 49 | if (!root) return 50 | 51 | root.onscroll = debounceFn( 52 | () => { 53 | const [visible] = [ 54 | ...document.getElementsByClassName('beatmap-result'), 55 | ].filter(x => inView(x)) 56 | 57 | const { search } = store.getState().router.location 58 | 59 | if (visible) store.dispatch(replace({ hash: visible.id, search })) 60 | else store.dispatch(replace({ hash: undefined, search })) 61 | }, 62 | { wait: 50 } 63 | ) 64 | } 65 | 66 | initScrollHandler() 67 | -------------------------------------------------------------------------------- /server/src/middleware/errors.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from 'koa' 2 | import { MongoError } from 'mongodb' 3 | import CodedError from '~utils/CodedError' 4 | import signale from '~utils/signale' 5 | import { SchemaValidationError } from '../routes/upload/parseValidationError' 6 | 7 | export const errorHandler: Middleware = async (ctx, next) => { 8 | try { 9 | await next() 10 | } catch (err) { 11 | if (err.name === 'ValidationError') { 12 | const errors: any[] = Object.values(err.errors) 13 | const fields = errors.map(({ path, value, kind }) => ({ 14 | kind, 15 | path, 16 | value, 17 | })) 18 | 19 | const body = { code: 0x00001, identifier: 'ERR_INVALID_FIELDS', fields } 20 | ctx.status = 400 21 | return (ctx.body = body) 22 | } else if (err instanceof MongoError) { 23 | if (err.code === 11000) { 24 | const body = { 25 | code: 0x00002, 26 | identifier: 'ERR_DUPLICATE_RESOURCE', 27 | } 28 | 29 | ctx.status = 422 30 | return (ctx.body = body) 31 | } 32 | 33 | throw err 34 | } else if (err instanceof SchemaValidationError) { 35 | const body = { 36 | ...err, 37 | code: 0x00004, 38 | identifier: 'ERR_SCHEMA_VALIDATION_FAILED', 39 | message: err.message, 40 | 41 | name: undefined, 42 | validationError: undefined, 43 | } 44 | 45 | ctx.status = 400 46 | return (ctx.body = body) 47 | } else if (err.code === 'LIMIT_FILE_SIZE') { 48 | const body = { 49 | code: 0x00003, 50 | field: err.field, 51 | identifier: 'ERR_FILE_TOO_LARGE', 52 | } 53 | 54 | ctx.status = 413 55 | return (ctx.body = body) 56 | } else if (err instanceof CodedError) { 57 | ctx.status = err.status 58 | return (ctx.body = err.body) 59 | } 60 | 61 | signale.error(err) 62 | return (ctx.status = 500) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /server/src/routes/manage.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from 'koa' 2 | import koaBody from 'koa-body' 3 | import passport from 'koa-passport' 4 | import Router from 'koa-router' 5 | import { clearCache } from '~middleware' 6 | import { Beatmap, IBeatmapModel, IUserModel } from '~mongo/models' 7 | import { parseKey } from '~utils/parseKey' 8 | 9 | const router = new Router({ 10 | prefix: '/manage', 11 | }) 12 | .use(passport.authenticate('jwt', { session: false })) 13 | .use(koaBody({ text: false, urlencoded: false })) 14 | 15 | const userBeatmap: Middleware = async (ctx, next) => { 16 | const key = parseKey(ctx.params.key, true) 17 | if (!key) return (ctx.status = 404) 18 | 19 | const map = await Beatmap.findOne({ key, deletedAt: null }) 20 | if (!map) return (ctx.status = 404) 21 | 22 | const user: IUserModel = ctx.state.user 23 | if (`${map.uploader}` !== `${user.id}` && user.admin === false) { 24 | return (ctx.status = 403) 25 | } 26 | 27 | ctx.beatmap = map 28 | return next() 29 | } 30 | 31 | router.post('/edit/:key', userBeatmap, async ctx => { 32 | const map: IBeatmapModel = ctx.beatmap 33 | const { name, description } = ctx.request.body || ({} as any) 34 | 35 | map.name = name 36 | map.description = description 37 | await map.save() 38 | 39 | await Promise.all([ 40 | clearCache(`key:${map.key}`), 41 | clearCache(`hash:${map.hash}`), 42 | clearCache('maps'), 43 | clearCache(`uploader:${map.uploader}`), 44 | ]) 45 | 46 | return (ctx.status = 204) 47 | }) 48 | 49 | router.post('/delete/:key', userBeatmap, async ctx => { 50 | const map: IBeatmapModel = ctx.beatmap 51 | 52 | map.deletedAt = new Date() 53 | await map.save() 54 | 55 | await Promise.all([ 56 | clearCache(`key:${map.key}`), 57 | clearCache(`hash:${map.hash}`), 58 | clearCache('maps'), 59 | clearCache(`uploader:${map.uploader}`), 60 | ]) 61 | 62 | return (ctx.status = 204) 63 | }) 64 | 65 | export { router as manageRouter } 66 | -------------------------------------------------------------------------------- /client/src/ts/store/scrollers/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Reducer } from 'redux' 2 | import { IScrollersState, ScrollerActionTypes } from './types' 3 | 4 | const initialState: IScrollersState = {} 5 | 6 | export const scrollersReducer: Reducer< 7 | IScrollersState, 8 | IAnyAction 9 | > = (state = initialState, action) => { 10 | switch (action.type) { 11 | case ScrollerActionTypes.INIT_SCROLLER: 12 | return { ...state, [action.payload.key]: action.payload } 13 | 14 | case ScrollerActionTypes.RESET_SCROLLER: 15 | return { ...state, [action.payload]: undefined } 16 | 17 | case ScrollerActionTypes.SET_LOADING: 18 | return { 19 | ...state, 20 | [action.payload.key]: { 21 | ...state[action.payload.key], 22 | loading: action.payload.value, 23 | }, 24 | } 25 | 26 | case ScrollerActionTypes.SET_ERROR: 27 | return { 28 | ...state, 29 | [action.payload.key]: { 30 | ...state[action.payload.key], 31 | error: action.payload.value, 32 | }, 33 | } 34 | 35 | case ScrollerActionTypes.SET_DONE: 36 | return { 37 | ...state, 38 | [action.payload.key]: { 39 | ...state[action.payload.key], 40 | done: action.payload.value, 41 | }, 42 | } 43 | 44 | case ScrollerActionTypes.SET_LAST_PAGE: 45 | return { 46 | ...state, 47 | [action.payload.key]: { 48 | ...state[action.payload.key], 49 | lastPage: action.payload.value, 50 | }, 51 | } 52 | 53 | case ScrollerActionTypes.APPEND_MAPS: 54 | return { 55 | ...state, 56 | [action.payload.key]: { 57 | ...state[action.payload.key], 58 | maps: [ 59 | ...(state[action.payload.key] || { maps: [] }).maps, 60 | ...action.payload.value, 61 | ], 62 | }, 63 | } 64 | 65 | default: 66 | return state 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /client/src/ts/routes/Uploader.tsx: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | import React, { FunctionComponent, useEffect, useState } from 'react' 3 | import Helmet from 'react-helmet' 4 | import { RouteComponentProps } from 'react-router-dom' 5 | import { BeatmapList } from '../components/Beatmap' 6 | import { Loader } from '../components/Loader' 7 | import { TextPage } from '../components/TextPage' 8 | import { axios } from '../utils/axios' 9 | import { NotFound } from './NotFound' 10 | 11 | interface IParams { 12 | id: string 13 | } 14 | 15 | export const Uploader: FunctionComponent> = ({ 16 | match, 17 | }) => { 18 | const userID = match.params.id 19 | const [user, setUser] = useState(undefined) 20 | 21 | useEffect(() => { 22 | axios 23 | .get(`/users/find/${userID}`) 24 | .then(resp => { 25 | setUser(resp.data) 26 | }) 27 | .catch(err => setUser(err)) 28 | 29 | return () => { 30 | setUser(undefined) 31 | } 32 | }, [userID]) 33 | 34 | if (user === undefined) return 35 | if (user instanceof Error) { 36 | const error = user as AxiosError 37 | if (error.response && error.response.status === 404) { 38 | return 39 | } 40 | 41 | return ( 42 | 43 |

    Failed to load users' beatmap info! Please try again later.

    44 |

    If the problem persists, please alert a site admin.

    45 |
    46 | ) 47 | } 48 | 49 | return ( 50 | <> 51 | 52 | BeatSaver - Beatmaps by {user.username} 53 | 54 | 55 |
    56 |

    57 | Beatmaps by {user.username} 58 |

    59 |
    60 |
    61 | 62 | 63 | 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /server/src/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import { hash } from 'bcrypt' 2 | import koaBody from 'koa-body' 3 | import passport from 'koa-passport' 4 | import Router from 'koa-router' 5 | import uuid from 'uuid/v4' 6 | import { BCRYPT_ROUNDS, IS_DEV } from '~environment' 7 | import mailDriver, { sendTo } from '~mail' 8 | import { IUserModel, User } from '~mongo/models' 9 | import { issueToken } from '~strategies' 10 | 11 | const sendMail = mailDriver() 12 | const router = new Router({ 13 | prefix: '/auth', 14 | }).use(koaBody({ text: false, urlencoded: false })) 15 | 16 | router.post('/register', async ctx => { 17 | const { username, email, password } = ctx.request.body 18 | 19 | const hashed = password ? await hash(password, BCRYPT_ROUNDS) : undefined 20 | const verifyToken = uuid() 21 | 22 | const user = await User.create({ 23 | email, 24 | password: hashed, 25 | username, 26 | verified: false, 27 | verifyToken, 28 | }) 29 | 30 | const verifyLink = IS_DEV 31 | ? `${ctx.origin}/auth/verify/${verifyToken}` 32 | : `${ctx.origin}/api/auth/verify/${verifyToken}` 33 | 34 | await sendMail( 35 | sendTo(user), 36 | 'BeatSaver Account Verification', 37 | `To verify your account, please click the link below:\n${verifyLink}` 38 | ) 39 | 40 | const token = await issueToken(user) 41 | ctx.set('x-auth-token', token) 42 | return (ctx.status = 204) 43 | }) 44 | 45 | router.get('/verify/:token', async ctx => { 46 | const verifyToken: string = ctx.params.token 47 | 48 | const user = await User.findOne({ verifyToken }) 49 | if (!user) return (ctx.status = 404) 50 | 51 | user.verified = true 52 | user.verifyToken = null 53 | await user.save() 54 | 55 | return ctx.redirect('/') 56 | }) 57 | 58 | router.post( 59 | '/login', 60 | passport.authenticate('local', { session: false }), 61 | async ctx => { 62 | const user: IUserModel = ctx.state.user 63 | const token = await issueToken(user) 64 | 65 | ctx.set('x-auth-token', token) 66 | return (ctx.status = 204) 67 | } 68 | ) 69 | 70 | export { router as authRouter } 71 | -------------------------------------------------------------------------------- /server/src/middleware/ratelimit.ts: -------------------------------------------------------------------------------- 1 | import { Context, Middleware } from 'koa' 2 | import koaRateLimit, { 3 | HeaderNameOptions, 4 | MiddlewareOptions, 5 | } from 'koa-ratelimit' 6 | import { rateLimitDB } from '~redis' 7 | 8 | interface IOptions { 9 | /** 10 | * The length of a single limiting period. This value is expressed 11 | * in milliseconds, defaulting to one hour. 12 | */ 13 | duration?: number 14 | 15 | /** 16 | * The maximum amount of requests a client (see the `id` field) may 17 | * make during a limiting period. (see `duration`) 18 | */ 19 | max?: number 20 | 21 | /** 22 | * Get the unique-identifier for a request. This defaults to the 23 | * client's IP address. Returning "false" will skip rate-limiting. 24 | */ 25 | id?: (context: Context) => string | false 26 | 27 | /** 28 | * Whether or not to disable the usage of rate limit headers. This defaults 29 | * to **false**. 30 | */ 31 | disableHeader?: boolean 32 | 33 | /** 34 | * The message used on the response body if a client is rate-limited. There is 35 | * a default message; which includes when they should try again. 36 | */ 37 | errorMessage?: string 38 | 39 | /** 40 | * Whether or not to throw an error upon being rate-limited. This uses 41 | * the Koa context function "throw". 42 | */ 43 | throw?: boolean 44 | 45 | /** 46 | * A relation of header to the header's display name. 47 | */ 48 | headers?: HeaderNameOptions 49 | } 50 | 51 | export function rateLimit(opts: IOptions): Middleware 52 | export function rateLimit(duration: number, max: number): Middleware 53 | export function rateLimit( 54 | optsOrDur: number | IOptions, 55 | max?: number 56 | ): Middleware { 57 | const opts: Partial = 58 | typeof optsOrDur === 'number' ? { duration: optsOrDur, max } : optsOrDur 59 | 60 | const defaultOpts: Partial = { 61 | headers: { 62 | remaining: 'Rate-Limit-Remaining', 63 | reset: 'Rate-Limit-Reset', 64 | total: 'Rate-Limit-Total', 65 | }, 66 | id: ctx => `${ctx.url}:${ctx.realIP}`, 67 | } 68 | 69 | const db = rateLimitDB as any 70 | const options: MiddlewareOptions = { ...defaultOpts, ...opts, db } 71 | 72 | return koaRateLimit(options) 73 | } 74 | -------------------------------------------------------------------------------- /server/src/routes/download.ts: -------------------------------------------------------------------------------- 1 | import cors from '@koa/cors' 2 | import Router from 'koa-router' 3 | import { DUMP_PATH } from '~constants' 4 | import { IS_DEV, PORT } from '~environment' 5 | import { delCache, rateLimit } from '~middleware' 6 | import { Beatmap } from '~mongo/models' 7 | import { globStats } from '~utils/fs' 8 | import { parseKey } from '~utils/parseKey' 9 | 10 | const router = new Router({ 11 | prefix: '/download', 12 | }).use(cors()) 13 | 14 | const limiter = rateLimit({ 15 | duration: 10 * 60 * 1000, 16 | max: 10, 17 | }) 18 | 19 | router.get('/key/:key', limiter, async ctx => { 20 | const key = parseKey(ctx.params.key) 21 | if (key === false) return (ctx.status = 404) 22 | 23 | const map = await Beatmap.findOne({ key, deletedAt: null }) 24 | if (!map) return (ctx.status = 404) 25 | 26 | map.stats.downloads += 1 27 | 28 | await Promise.all([ 29 | map.save(), 30 | delCache(`stats:key:${map.key}`), 31 | delCache(`stats:hash:${map.hash}`), 32 | ]) 33 | 34 | return ctx.redirect(map.directDownload) 35 | }) 36 | 37 | router.get('/hash/:hash', limiter, async ctx => { 38 | const { hash } = ctx.params 39 | 40 | const map = await Beatmap.findOne({ hash, deletedAt: null }) 41 | if (!map) return (ctx.status = 404) 42 | 43 | map.stats.downloads += 1 44 | 45 | await Promise.all([ 46 | map.save(), 47 | delCache(`stats:key:${map.key}`), 48 | delCache(`stats:hash:${map.hash}`), 49 | ]) 50 | 51 | return ctx.redirect(map.directDownload) 52 | }) 53 | 54 | const dumpLimit = rateLimit({ 55 | duration: 1000, 56 | max: 5, 57 | }) 58 | 59 | router.get('/dump/:type', dumpLimit, async ctx => { 60 | const type: string = ctx.params.type 61 | if (type !== 'maps' && type !== 'users') return (ctx.status = 404) 62 | 63 | const files = await globStats([`${type}.*.json`, `!${type}.temp.json`], { 64 | cwd: DUMP_PATH, 65 | }) 66 | 67 | const [path] = [...files] 68 | .sort((a, b) => b.mtimeMs - a.mtimeMs) 69 | .map(x => x.path) 70 | 71 | if (!path) return (ctx.status = 503) 72 | const cdnPath = `/cdn/dumps/${path}` 73 | const absolute = IS_DEV ? `http://localhost:${PORT}${cdnPath}` : cdnPath 74 | 75 | return ctx.redirect(absolute) 76 | }) 77 | 78 | export { router as downloadRouter } 79 | -------------------------------------------------------------------------------- /server/src/routes/admin.ts: -------------------------------------------------------------------------------- 1 | import passport from 'koa-passport' 2 | import Router from 'koa-router' 3 | import { Beatmap, IUserModel } from '~mongo/models' 4 | import { cacheDB, rateLimitDB } from '~redis' 5 | import * as schemas from '~utils/schemas' 6 | import signale from '~utils/signale' 7 | 8 | const router = new Router({ 9 | prefix: '/admin', 10 | }) 11 | 12 | router.use(passport.authenticate('jwt', { session: false })) 13 | router.use(async (ctx, next) => { 14 | const user: IUserModel = ctx.state.user 15 | if (!user.admin) return (ctx.status = 401) 16 | 17 | await next() 18 | }) 19 | 20 | router.post( 21 | '/migrate-votes', 22 | ctx => 23 | new Promise((resolve, reject) => { 24 | Beatmap.find({ 'stats.upVotes': { $exists: false } }) 25 | .cursor() 26 | .on('data', doc => 27 | doc.save().then(() => { 28 | // No-op 29 | }) 30 | ) 31 | .on('end', () => resolve((ctx.status = 204))) 32 | .on('error', err => reject(err)) 33 | }) 34 | ) 35 | 36 | const elasticSync = (force?: boolean) => 37 | new Promise(async (resolve, reject) => { 38 | const truncate = () => 39 | new Promise((r, rj) => { 40 | Beatmap.esTruncate(err => { 41 | if (err) rj(err) 42 | else r() 43 | }) 44 | }) 45 | 46 | signale.start('Starting elasticsearch sync...') 47 | if (force) await truncate() 48 | 49 | let count = 0 50 | Beatmap.synchronize() 51 | .on('data', () => { 52 | count++ 53 | }) 54 | .on('close', () => { 55 | signale.complete('Elasticsearch sync complete!') 56 | resolve(count) 57 | }) 58 | .on('error', err => reject(err)) 59 | }) 60 | 61 | router.post('/elastic-sync/:force?', async ctx => { 62 | const force = !!ctx.params.force 63 | ctx.status = 204 64 | 65 | elasticSync(force) 66 | }) 67 | 68 | router.post('/sync-schemas', async ctx => { 69 | await schemas.sync() 70 | return (ctx.status = 204) 71 | }) 72 | 73 | router.post('/flush-cache', async ctx => { 74 | await cacheDB.flushdb() 75 | return (ctx.status = 204) 76 | }) 77 | 78 | router.post('/reset-rate-limits', async ctx => { 79 | await rateLimitDB.flushdb() 80 | return (ctx.status = 204) 81 | }) 82 | 83 | export { router as adminRouter } 84 | -------------------------------------------------------------------------------- /server/src/routes/stats.ts: -------------------------------------------------------------------------------- 1 | import cors from '@koa/cors' 2 | import Router from 'koa-router' 3 | import { getCache, rateLimit, setCache } from '~middleware' 4 | import { Beatmap } from '~mongo/models' 5 | import { parseKey } from '~utils/parseKey' 6 | 7 | const router = new Router({ 8 | prefix: '/stats', 9 | }) 10 | .use(cors()) 11 | .use( 12 | rateLimit({ 13 | duration: 1 * 1000, 14 | max: 10, 15 | }) 16 | ) 17 | 18 | const findMap = async (query: T) => { 19 | const map = await Beatmap.findOne( 20 | { ...query, deletedAt: null }, 21 | { stats: 1, _id: 1, hash: 1, key: 1 } 22 | ) 23 | 24 | if (!map) return null 25 | const transformed = { 26 | _id: map._id, 27 | hash: map.hash, 28 | key: map.key, 29 | stats: map.stats, 30 | } 31 | 32 | return transformed 33 | } 34 | 35 | router.get('/key/:key', async ctx => { 36 | ctx.set('Content-Type', 'application/json; charset=utf-8') 37 | const cache = await getCache(`stats:key:${ctx.params.key}`) 38 | 39 | if (cache) { 40 | ctx.set('X-Koa-Redis-Cache', 'true') 41 | return (ctx.body = cache) 42 | } 43 | 44 | const key = parseKey(ctx.params.key) 45 | if (key === false) return (ctx.status = 404) 46 | 47 | const map = await findMap({ key }) 48 | if (!map) return (ctx.status = 404) 49 | 50 | const mapEncoded = JSON.stringify(map) 51 | await setCache(`stats:key:${ctx.params.key}`, mapEncoded, 60 * 10) 52 | ctx.set('X-Koa-Redis-Cache', 'false') 53 | return (ctx.body = mapEncoded) 54 | }) 55 | 56 | router.get('/by-hash/:hash', async ctx => { 57 | if (typeof ctx.params.hash !== 'string') return (ctx.status = 400) 58 | 59 | ctx.set('Content-Type', 'application/json; charset=utf-8') 60 | const cache = await getCache(`stats:hash:${ctx.params.hash}`) 61 | 62 | if (cache) { 63 | ctx.set('X-Koa-Redis-Cache', 'true') 64 | return (ctx.body = cache) 65 | } 66 | 67 | const hash = ctx.params.hash.toLowerCase() 68 | 69 | const map = await findMap({ hash }) 70 | if (!map) return (ctx.status = 404) 71 | 72 | const mapEncoded = JSON.stringify(map) 73 | await setCache(`stats:hash:${ctx.params.hash}`, mapEncoded, 60 * 10) 74 | ctx.set('X-Koa-Redis-Cache', 'false') 75 | 76 | return (ctx.body = mapEncoded) 77 | }) 78 | 79 | export { router as statsRouter } 80 | -------------------------------------------------------------------------------- /docs/content/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} title Sidebar Title 3 | * @param {string[]} routes Routes 4 | * @param {boolean} [collapsable] Collapsible Title 5 | * @returns {{}} 6 | */ 7 | function generateSidebar(title, routes, collapsable = false) { 8 | return [{ 9 | title, 10 | collapsable, 11 | children: routes, 12 | }] 13 | } 14 | 15 | module.exports = { 16 | title: 'BeatSaver Reloaded', 17 | 18 | head: [ 19 | ['link', { rel: 'icon', href: '/favicon.png' }], 20 | ], 21 | 22 | theme: 'yuu', 23 | themeConfig: { 24 | yuu: { 25 | defaultColorTheme: 'blue', 26 | }, 27 | 28 | repo: 'lolPants/beatsaver-reloaded', 29 | docsDir: 'docs/content', 30 | editLinks: true, 31 | editLinkText: 'Help improve this page!', 32 | lastUpdated: 'Last Updated', 33 | 34 | displayAllHeaders: true, 35 | sidebar: { 36 | '/endpoints/': generateSidebar('Endpoints', [ 37 | '', 38 | 'maps', 39 | 'search', 40 | 'vote', 41 | 'download', 42 | 'dump', 43 | 'auth', 44 | 'users', 45 | ]), 46 | '/responses/': generateSidebar('Responses', [ 47 | '', 48 | 'pagination', 49 | 'beatmap', 50 | 'user', 51 | ]), 52 | '/usage/': generateSidebar('Usage', [ 53 | '', 54 | 'semantics', 55 | 'errors', 56 | 'rate-limits', 57 | ]), 58 | }, 59 | 60 | nav: [ 61 | { text: 'Usage', link: '/usage/' }, 62 | { text: 'Endpoints', link: '/endpoints/' }, 63 | { text: 'Responses', link: '/responses/' }, 64 | ], 65 | }, 66 | plugins: [ 67 | ['@vuepress/last-updated', { 68 | transformer: timestamp => { 69 | const dateformat = require('dateformat') 70 | return dateformat(timestamp, 'yyyy/mm/dd hh:MM:ss TT') 71 | }, 72 | }], 73 | ['@vuepress/medium-zoom', { 74 | options: { 75 | margin: 8, 76 | background: '#21253073', 77 | }, 78 | }], 79 | '@vuepress/nprogress', 80 | ['container', { 81 | type: 'feature', 82 | before: info => `

    ${info}

    `, 83 | after: '
    ', 84 | }], 85 | ], 86 | configureWebpack: { 87 | resolve: { 88 | alias: { 89 | '@': '../', 90 | }, 91 | }, 92 | }, 93 | } 94 | -------------------------------------------------------------------------------- /server/src/mongo/plugins/mongoosastic.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'mongoosastic' { 2 | import { Schema } from 'mongoose' 3 | 4 | declare interface IMongoosasticOptions { 5 | index: string 6 | type: string 7 | 8 | hosts: string[] 9 | host: string 10 | port: number 11 | protocol: 'http' | 'https' 12 | 13 | populate: Array<{ path: string; select?: string }> 14 | hydrate: boolean 15 | indexAutomatically: boolean 16 | saveOnSynchronize: boolean 17 | } 18 | 19 | function mongoosastic( 20 | schema: Schema, 21 | options: Partial 22 | ): any 23 | 24 | export default mongoosastic 25 | } 26 | 27 | declare module 'mongoose' { 28 | import { Readable } from 'stream' 29 | 30 | // tslint:disable-next-line: interface-name 31 | declare interface SchemaTypeOpts { 32 | es_indexed?: boolean 33 | es_type?: 'object' | 'nested' 34 | es_include_in_parent?: boolean 35 | es_boost?: number 36 | es_null_value?: any 37 | es_type?: 'integer' | 'date' | 'string' 38 | es_schema?: Schema 39 | } 40 | 41 | declare interface ISearchResponse { 42 | took: number 43 | timed_out: boolean 44 | _shards: { 45 | total: number 46 | successful: number 47 | skipped: number 48 | failed: number 49 | } 50 | 51 | hits: { 52 | max_score: number | null 53 | total: { 54 | value: number 55 | relation: string 56 | } 57 | 58 | hits: Array<{ 59 | _index: string 60 | _type: string 61 | _id: string 62 | _score: number 63 | _source: T 64 | }> 65 | } 66 | } 67 | 68 | declare interface IFullSearch { 69 | query: any 70 | from: number 71 | size: number 72 | } 73 | 74 | // tslint:disable-next-line: interface-name 75 | declare interface Model { 76 | public synchronize(query?: any): Readable 77 | public esTruncate(callback: (err: Error | undefined) => any): void 78 | 79 | public search( 80 | query: any, 81 | callback: (err: Error | undefined, results: ISearchResponse) => any 82 | ): void 83 | 84 | public esSearch( 85 | query: IFullSearch, 86 | callback: (err: Error | undefined, results: ISearchResponse) => any 87 | ): void 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /client/src/ts/components/LegalPage.tsx: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | import Markdown from 'markdown-to-jsx' 3 | import React, { FunctionComponent, useEffect, useState } from 'react' 4 | import { NotFound } from '../routes/NotFound' 5 | import { axios } from '../utils/axios' 6 | import { Loader } from './Loader' 7 | import { TextPage } from './TextPage' 8 | 9 | interface IProps { 10 | title: string 11 | url: string 12 | } 13 | 14 | type LoadState = 'loading' | 'loaded' | 'errored' | 'not-implemented' 15 | const notImplemented = '@@loadState/NOT_IMPLEMENTED' 16 | 17 | export const LegalPage: FunctionComponent = ({ title, url }) => { 18 | const stored = sessionStorage.getItem(`legal:${url}`) 19 | 20 | const [content, setContent] = useState( 21 | stored === null || stored === null ? '' : stored 22 | ) 23 | 24 | const [state, setState] = useState( 25 | stored === null 26 | ? 'loading' 27 | : stored === notImplemented 28 | ? 'not-implemented' 29 | : 'loaded' 30 | ) 31 | 32 | useEffect(() => { 33 | if (state !== 'loading') return 34 | 35 | axios 36 | .get(`/legal/${url}`) 37 | .then(resp => { 38 | setState('loaded') 39 | setContent(resp.data) 40 | sessionStorage.setItem(`legal:${url}`, resp.data) 41 | }) 42 | .catch(e => { 43 | const err = e as AxiosError 44 | const resp = err.response 45 | 46 | if (resp && resp.status === 501) { 47 | sessionStorage.setItem(`legal:${url}`, notImplemented) 48 | setState('not-implemented') 49 | } else { 50 | setState('errored') 51 | } 52 | }) 53 | }, [title, url]) 54 | 55 | if (state === 'not-implemented') return 56 | if (state === 'loading') return 57 | if (state === 'errored') { 58 | return ( 59 | 60 |

    Could not load page content, please contact a site administrator.

    61 |
    62 | ) 63 | } 64 | 65 | return ( 66 | 67 |
    68 | {content} 69 |
    70 |
    71 | ) 72 | } 73 | 74 | export const DMCAPage = () => 75 | export const PrivacyPage = () => 76 | -------------------------------------------------------------------------------- /client/src/ts/components/Beatmap/BeatmapScroller.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useEffect, useState } from 'react' 2 | import Helmet from 'react-helmet' 3 | import { useInView } from 'react-intersection-observer' 4 | import { history } from '../../init' 5 | import { IScroller } from '../../store/scrollers' 6 | import { checkHash } from '../../utils/scroll' 7 | import { APIError } from '../APIError' 8 | import { Loader } from '../Loader' 9 | import { BeatmapResult } from './BeatmapResult' 10 | 11 | interface IProps { 12 | scroller: IScroller 13 | 14 | finite: boolean | undefined 15 | fallback?: JSX.Element 16 | next: () => any 17 | } 18 | 19 | export const BeatmapScroller: FunctionComponent = ({ 20 | scroller: { maps, loading, done, error, type }, 21 | 22 | finite, 23 | fallback, 24 | next, 25 | }) => { 26 | const [ref, inView] = useInView({ rootMargin: '240px' }) 27 | const [scrolled, setScrolled] = useState(false) 28 | 29 | useEffect(() => { 30 | if (maps.length === 0) return 31 | if (scrolled) return 32 | if (history.action !== 'POP') return 33 | 34 | setScrolled(true) 35 | checkHash() 36 | }, [maps.length]) 37 | 38 | if (inView && !loading && !done && !finite) next() 39 | if (error) return 40 | 41 | const capitalize = (s: string) => { 42 | if (typeof s !== 'string') return '' 43 | return s.charAt(0).toUpperCase() + s.slice(1) 44 | } 45 | 46 | const title = 47 | type === 'text' 48 | ? 'Search' 49 | : type === 'uploader' 50 | ? undefined 51 | : capitalize(type) 52 | 53 | return ( 54 | <> 55 | {title === undefined ? null : ( 56 | 57 | BeatSaver - {title} 58 | 59 | )} 60 | 61 | {maps.length === 0 62 | ? fallback || null 63 | : maps.map(m => )} 64 | 65 | {!loading || done ? null : } 66 | 67 | {!loading && !done && maps.length > 0 ? ( 68 |
    69 | ) : null} 70 | 71 | {!finite ? null : ( 72 | 79 | )} 80 | 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /client/src/ts/store/audio/actions.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip' 2 | import { IState } from '..' 3 | import { AudioActionTypes } from './types' 4 | 5 | type TypedThunk

    = Thunk 6 | 7 | export type PreviewBeatmap = ThunkFunction 8 | export const previewBeatmap: ( 9 | beatmap: IBeatmap 10 | ) => TypedThunk = beatmap => async (dispatch, getState) => { 11 | stopPreview()(dispatch, getState) 12 | 13 | dispatch({ 14 | payload: true, 15 | type: AudioActionTypes.SET_LOADING, 16 | }) 17 | 18 | dispatch({ 19 | payload: beatmap.key, 20 | type: AudioActionTypes.SET_PLAYING_KEY, 21 | }) 22 | 23 | try { 24 | const audioURL = await getAudioURL(beatmap, getState) 25 | dispatch({ 26 | payload: { key: beatmap.key, value: audioURL }, 27 | type: AudioActionTypes.ADD_AUDIO_URL, 28 | }) 29 | 30 | dispatch({ 31 | payload: false, 32 | type: AudioActionTypes.SET_LOADING, 33 | }) 34 | 35 | dispatch({ 36 | payload: audioURL, 37 | type: AudioActionTypes.SET_SOURCE, 38 | }) 39 | 40 | dispatch({ 41 | type: AudioActionTypes.PLAY, 42 | }) 43 | 44 | dispatch({ 45 | payload: null, 46 | type: AudioActionTypes.SET_ERROR, 47 | }) 48 | } catch (err) { 49 | console.error(err) 50 | 51 | dispatch({ 52 | payload: false, 53 | type: AudioActionTypes.SET_LOADING, 54 | }) 55 | 56 | dispatch({ 57 | payload: err, 58 | type: AudioActionTypes.SET_ERROR, 59 | }) 60 | } 61 | } 62 | 63 | const getAudioURL: ( 64 | beatmap: IBeatmap, 65 | getState: () => IState 66 | ) => Promise = async (beatmap, getState) => { 67 | const { urls } = getState().audio 68 | const url = urls[beatmap.key] 69 | if (url) return url 70 | 71 | const resp = await fetch(beatmap.directDownload) 72 | const blob = await resp.blob() 73 | 74 | const zip = new JSZip() 75 | await zip.loadAsync(blob) 76 | 77 | const info = zip.file('info.dat') 78 | const infoJSON = JSON.parse(await info.async('text')) 79 | 80 | const songFilename: string = infoJSON._songFilename 81 | const audioFile = zip.file(songFilename) 82 | const audio = await audioFile.async('blob') 83 | return URL.createObjectURL(audio) 84 | } 85 | 86 | export type StopPreview = ThunkFunction 87 | export const stopPreview: () => TypedThunk = () => dispatch => { 88 | dispatch({ 89 | type: AudioActionTypes.STOP, 90 | }) 91 | } 92 | -------------------------------------------------------------------------------- /client/src/ts/components/Beatmap/BeatmapAPI.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionComponent, useEffect } from 'react' 2 | import { connect, MapStateToProps } from 'react-redux' 3 | import { IState } from '../../store' 4 | import { IScroller } from '../../store/scrollers' 5 | import { 6 | RequestNextMaps, 7 | requestNextMaps as requestNextMapsFn, 8 | } from '../../store/scrollers' 9 | 10 | interface IRenderProps { 11 | scroller: IScroller 12 | next: () => any 13 | } 14 | 15 | interface ICommonProps { 16 | render: (props: IRenderProps) => JSX.Element 17 | } 18 | 19 | interface IConnectedProps { 20 | scroller: IScroller 21 | } 22 | 23 | interface IFunctionProps { 24 | requestNextMaps: RequestNextMaps 25 | } 26 | 27 | type IPassedProps = ICommonProps & IBeatmapSearch 28 | type IProps = IPassedProps & IConnectedProps & IFunctionProps 29 | 30 | const BeatmapAPI: FunctionComponent = ({ 31 | render, 32 | scroller, 33 | requestNextMaps, 34 | }) => { 35 | const request = () => 36 | requestNextMaps(scroller.key, scroller.type, scroller.query) 37 | 38 | const next = () => { 39 | if (scroller.maps.length !== 0) request() 40 | } 41 | 42 | useEffect(() => { 43 | if (scroller.maps.length === 0) request() 44 | }, [scroller.key]) 45 | 46 | return render({ scroller, next }) 47 | } 48 | 49 | export type SearchType = 'latest' | 'hot' | 'downloads' | 'plays' | 'rating' 50 | interface ISearchProps { 51 | type: SearchType 52 | query?: string 53 | } 54 | 55 | export type QueryType = 'text' | 'hash' | 'uploader' 56 | interface IQueryProps { 57 | type: QueryType 58 | query: string 59 | } 60 | 61 | export type SearchTypes = SearchType | QueryType 62 | export type IBeatmapSearch = ISearchProps | IQueryProps 63 | 64 | const mapStateToProps: MapStateToProps< 65 | IConnectedProps, 66 | IPassedProps, 67 | IState 68 | > = (state, { type, query }) => { 69 | const key = query !== undefined ? `${type}?q=${query}` : type 70 | 71 | const defaultScroller: IScroller = { 72 | key, 73 | query, 74 | type, 75 | 76 | done: false, 77 | error: undefined, 78 | lastPage: null, 79 | loading: false, 80 | maps: [], 81 | } 82 | 83 | return { 84 | scroller: state.scrollers[key] || defaultScroller, 85 | } 86 | } 87 | 88 | const dispatchProps: IFunctionProps = { 89 | requestNextMaps: requestNextMapsFn, 90 | } 91 | 92 | const ConnectedBeatmapAPI = connect(mapStateToProps, dispatchProps)(BeatmapAPI) 93 | export { ConnectedBeatmapAPI as BeatmapAPI } 94 | -------------------------------------------------------------------------------- /server/src/routes/upload/parseValidationError.ts: -------------------------------------------------------------------------------- 1 | import { AdditionalPropertiesParams, ErrorObject, ValidateFunction } from 'ajv' 2 | 3 | export class SchemaValidationError extends Error { 4 | public readonly filename: string 5 | public readonly path: string | null 6 | public readonly validationError: ErrorObject 7 | 8 | constructor(filename: string, error: ErrorObject, message: string) { 9 | super(message) 10 | 11 | this.name = 'SchemaValidationError' 12 | this.filename = filename 13 | this.path = error.dataPath === '' ? null : error.dataPath 14 | this.validationError = error 15 | } 16 | } 17 | 18 | export const parseValidationError = ( 19 | filename: string, 20 | errors: ValidateFunction['errors'] 21 | ) => { 22 | if (errors === null) return 23 | if (errors === undefined) return 24 | 25 | const [error] = errors 26 | switch (error.keyword) { 27 | case 'pattern': 28 | parsePattern(filename, error) 29 | case 'additionalProperties': 30 | parseAdditionalProps(filename, error) 31 | default: 32 | parseDefaultError(filename, error) 33 | } 34 | } 35 | 36 | type ParseError = (filename: string, error: ErrorObject) => void 37 | const parseDefaultError: ParseError = (filename, error) => { 38 | throw new SchemaValidationError( 39 | filename, 40 | error, 41 | error.message || 'has an unknown validation error' 42 | ) 43 | } 44 | 45 | const parsePattern: ParseError = (filename, error) => { 46 | if (error.message === undefined) { 47 | throw new SchemaValidationError(filename, error, 'is invalid') 48 | } 49 | 50 | // Version Validation 51 | if (error.message.includes('^(0|[2-9]\\d*)')) { 52 | throw new SchemaValidationError(filename, error, 'is invalid') 53 | } 54 | 55 | if (error.message.includes('^(.+)$')) { 56 | throw new SchemaValidationError(filename, error, 'cannot be blank') 57 | } 58 | 59 | // File Regex 60 | if (error.message.includes('com[1-9]')) { 61 | throw new SchemaValidationError( 62 | filename, 63 | error, 64 | 'contains an invalid filename' 65 | ) 66 | } 67 | 68 | parseDefaultError(filename, error) 69 | } 70 | 71 | const parseAdditionalProps: ParseError = (filename, error) => { 72 | const params = error.params as AdditionalPropertiesParams 73 | const property = params.additionalProperty 74 | 75 | throw new SchemaValidationError( 76 | filename, 77 | error, 78 | `should NOT have additional property: \`${property}\`` 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /server/src/middleware/cache.ts: -------------------------------------------------------------------------------- 1 | import { Middleware } from 'koa' 2 | import redisCache, { CacheOptions } from 'koa-redis-cache' 3 | import { 4 | CACHE_DRIVER, 5 | REDIS_HOST, 6 | REDIS_PASSWORD, 7 | REDIS_PORT, 8 | } from '~environment' 9 | import { CACHE_DB, cacheDB } from '~redis' 10 | 11 | const noCache: Middleware = (_, next) => next() 12 | 13 | export async function getCache(key: string) { 14 | const stat = await cacheDB.get(key) 15 | return stat 16 | } 17 | 18 | export async function setCache(key: string, map: string, expire: number) { 19 | const result = await cacheDB.set(key, map, 'EX', expire) 20 | return result 21 | } 22 | 23 | export async function delCache(key: string) { 24 | const result = await cacheDB.del(key) 25 | return result 26 | } 27 | 28 | export const cache = (opts?: CacheOptions) => { 29 | if (CACHE_DRIVER !== 'redis') return noCache 30 | 31 | const options: CacheOptions = { 32 | expire: 10 * 60, 33 | 34 | ...opts, 35 | 36 | prefix: 37 | opts && opts.prefix 38 | ? typeof opts.prefix === 'string' 39 | ? `${opts.prefix}:` 40 | : opts.prefix 41 | : undefined, 42 | 43 | redis: { 44 | host: REDIS_HOST, 45 | options: { 46 | db: CACHE_DB, 47 | password: REDIS_PASSWORD, 48 | }, 49 | port: REDIS_PORT, 50 | }, 51 | } 52 | 53 | return redisCache(options) 54 | } 55 | 56 | export const cacheHeaders: Middleware = async (ctx, next) => { 57 | await next() 58 | 59 | ctx.set( 60 | 'X-Cache-Status', 61 | ctx.response.headers['x-koa-redis-cache'] === 'true' ? 'HIT' : 'MISS' 62 | ) 63 | 64 | ctx.remove('X-Koa-Redis-Cache') 65 | } 66 | 67 | export const clearCache: (prefix?: string) => Promise = ( 68 | prefix = 'koa-redis-cache' 69 | ) => 70 | new Promise((resolve, reject) => { 71 | try { 72 | const keys: string[] = [] 73 | const stream = cacheDB.scanStream({ match: `${prefix}:*` }) 74 | stream 75 | .on('data', (k: string[]) => keys.push(...k)) 76 | .on('end', async () => { 77 | const pipeline = cacheDB.pipeline() 78 | 79 | for (const key of keys) { 80 | pipeline.del(key) 81 | } 82 | 83 | try { 84 | await pipeline.exec() 85 | resolve() 86 | } catch (err) { 87 | return reject(err) 88 | } 89 | }) 90 | .on('error', err => reject(err)) 91 | } catch (err) { 92 | reject(err) 93 | } 94 | }) 95 | -------------------------------------------------------------------------------- /client/src/ts/remote/request.ts: -------------------------------------------------------------------------------- 1 | import pDebounce from 'p-debounce' 2 | import { Dispatch } from 'redux' 3 | import { SearchTypes } from '../components/Beatmap/BeatmapAPI' 4 | import { IState } from '../store' 5 | import { ScrollerActionTypes } from '../store/scrollers' 6 | import { initializeScroller } from '../store/scrollers/actions' 7 | import { axios } from '../utils/axios' 8 | 9 | const request: ( 10 | dispatch: Dispatch>, 11 | getState: () => IState, 12 | key: string, 13 | type: SearchTypes, 14 | query: string | undefined 15 | ) => Promise = async (dispatch, getState, key, type, query) => { 16 | initializeScroller(key, type, query)(dispatch, getState) 17 | const scroller = getState().scrollers[key] 18 | if (scroller === undefined) return undefined 19 | 20 | const isUser = type === 'uploader' 21 | const isSearch = type === 'text' || type === 'hash' 22 | const page = scroller.lastPage === null ? 0 : scroller.lastPage + 1 23 | 24 | if ((isUser || isSearch) && !query) { 25 | dispatch({ 26 | payload: { key, value: true }, 27 | type: ScrollerActionTypes.SET_DONE, 28 | }) 29 | 30 | return 31 | } 32 | 33 | const url = isUser 34 | ? `/maps/${type}/${query}/${page}` 35 | : isSearch 36 | ? `/search/${type}/${page}?q=${encodeURIComponent(query || '')}` 37 | : `/maps/${type}/${page}` 38 | 39 | dispatch({ 40 | payload: { key, value: true }, 41 | type: ScrollerActionTypes.SET_LOADING, 42 | }) 43 | 44 | try { 45 | const resp = await axios.get(url) 46 | dispatch({ 47 | payload: { key, value: false }, 48 | type: ScrollerActionTypes.SET_LOADING, 49 | }) 50 | 51 | if (resp.data.nextPage === null) { 52 | dispatch({ 53 | payload: { key, value: true }, 54 | type: ScrollerActionTypes.SET_DONE, 55 | }) 56 | } 57 | 58 | dispatch({ 59 | payload: { key, value: page }, 60 | type: ScrollerActionTypes.SET_LAST_PAGE, 61 | }) 62 | 63 | dispatch({ 64 | payload: { key, value: resp.data.docs }, 65 | type: ScrollerActionTypes.APPEND_MAPS, 66 | }) 67 | } catch (err) { 68 | dispatch({ 69 | payload: { key, value: false }, 70 | type: ScrollerActionTypes.SET_LOADING, 71 | }) 72 | 73 | dispatch({ 74 | payload: { key, value: err }, 75 | type: ScrollerActionTypes.SET_ERROR, 76 | }) 77 | } 78 | } 79 | 80 | const dRequest = pDebounce(request, 300) 81 | export { dRequest as request } 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎵 BeatSaver Reloaded 2 | 3 | [![Build Status](https://img.shields.io/travis/com/lolpants/beatsaver-reloaded.svg?style=flat-square)](https://travis-ci.com/lolPants/beatsaver-reloaded) 4 | [![Docker Image Size](https://img.shields.io/microbadger/image-size/lolpants/beatsaver-reloaded/client-latest.svg?label=client%20image&style=flat-square)](https://hub.docker.com/r/lolpants/beatsaver-reloaded) 5 | [![Docker Image Size](https://img.shields.io/microbadger/image-size/lolpants/beatsaver-reloaded/server-latest.svg?label=server%20image&style=flat-square)](https://hub.docker.com/r/lolpants/beatsaver-reloaded) 6 | [![Docker Pulls](https://img.shields.io/docker/pulls/lolpants/beatsaver-reloaded.svg?style=flat-square&color=blue)](https://hub.docker.com/r/lolpants/beatsaver-reloaded) 7 | 8 | ## ⚠ Legal Notice 9 | I do not operate BeatSaver, this repo is just the code that powers BeatSaver and *anyone* is free to use it. **Only host beatmaps with audio that you own the legal copyright to.** I am not responsible for any legal trouble you run into using this code to host beatmaps. 10 | 11 | In addition to this, standard open-source licensing applies to this project. If you wish to use BeatSaver Reloaded for your own purposes, you must adhere to the ISC License terms, as documented in this project's [LICENSE file](https://github.com/lolPants/beatsaver-reloaded/blob/master/LICENSE). 12 | 13 | ## 🚀 Running in Production 14 | This project uses Travis to run automated docker builds, you can find the project on [Docker Hub](https://hub.docker.com/r/lolpants/beatsaver-reloaded). A sample Docker Compose file has been provided for you to use. 15 | 16 | It is recommended to use Redis caching and a long, random JWT token in production. 17 | 18 | ### 🛑 Prerequisites 19 | * Docker 20 | * Docker Compose *(optional, recommended)* 21 | * MongoDB 22 | * Redis 23 | 24 | ### 📝 Configuration 25 | Configuration is done using environment variables. Please refer to `.env.example` for more information. 26 | 27 | ## 🔧 Developing 28 | If you wish to contribute, please refer to the [contribution guidelines](https://github.com/lolPants/beatsaver-reloaded/blob/master/.github/CONTRIBUTING.md) or the documentation. 29 | 30 | ## ℹ Documentation 31 | Documentation is available as a vuepress site at `/docs`. 32 | 33 | The code that builds the site is licensed under the [project's ISC License](https://github.com/lolPants/beatsaver-reloaded/blob/master/LICENSE). However the content of the documentation is licensed using the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0) license, as documented [here](https://github.com/lolPants/beatsaver-reloaded/tree/master/docs/LICENSE). 34 | -------------------------------------------------------------------------------- /server/src/routes/upload/types.ts: -------------------------------------------------------------------------------- 1 | declare interface IBeatmapInfo { 2 | _version: string 3 | _songName: string 4 | _songSubName: string 5 | _songAuthorName: string 6 | _levelAuthorName: string 7 | _beatsPerMinute: number 8 | _songFilename: string 9 | _coverImageFilename: string 10 | _difficultyBeatmapSets: IBeatmapSet[] 11 | } 12 | 13 | declare interface IBeatmapSet { 14 | _beatmapCharacteristicName: string 15 | _difficultyBeatmaps: IDifficultyBeatmap[] 16 | } 17 | 18 | declare interface IDifficultyBeatmap { 19 | _difficulty: string 20 | _difficultyRank: number 21 | _beatmapFilename: string 22 | _noteJumpMovementSpeed: number 23 | _noteJumpStartBeatOffset: number 24 | } 25 | 26 | declare interface IDifficultyJSON { 27 | _version: string 28 | _BPMChanges: IBPMChange[] 29 | _events: IEvent[] 30 | _notes: INote[] 31 | _obstacles: IObstacle[] 32 | _bookmarks: IBookmark[] 33 | } 34 | 35 | declare interface IBPMChange { 36 | _BPM: number 37 | _time: number 38 | _beatsPerBar: number 39 | _metronomeOffset: number 40 | } 41 | 42 | declare interface IEvent { 43 | _time: number 44 | _type: number 45 | _value: number 46 | } 47 | 48 | declare interface INote { 49 | _time: number 50 | _lineIndex: number 51 | _lineLayer: number 52 | _type: number 53 | _cutDirection: number 54 | } 55 | 56 | declare interface IObstacle { 57 | _time: number 58 | _lineIndex: number 59 | _type: number 60 | _duration: number 61 | _width: number 62 | } 63 | 64 | declare interface IBookmark { 65 | _time: number 66 | _name: string 67 | } 68 | 69 | declare interface IParsedBeatmap { 70 | metadata: { 71 | songName: string 72 | songSubName: string 73 | songAuthorName: string 74 | levelAuthorName: string 75 | 76 | duration: number 77 | bpm: number 78 | 79 | difficulties: { 80 | easy: boolean 81 | normal: boolean 82 | hard: boolean 83 | expert: boolean 84 | expertPlus: boolean 85 | } 86 | 87 | characteristics: IBeatmapCharacteristic[] 88 | } 89 | 90 | hash: string 91 | coverExt: string 92 | } 93 | 94 | declare interface IBeatmapCharacteristic { 95 | name: string 96 | difficulties: { 97 | easy: IParsedDifficulty | null 98 | normal: IParsedDifficulty | null 99 | hard: IParsedDifficulty | null 100 | expert: IParsedDifficulty | null 101 | expertPlus: IParsedDifficulty | null 102 | } 103 | } 104 | 105 | declare interface IParsedDifficulty { 106 | duration: number 107 | length: number 108 | bombs: number 109 | notes: number 110 | obstacles: number 111 | njs: number 112 | njsOffset: number 113 | } 114 | -------------------------------------------------------------------------------- /client/src/sass/_bulma.scss: -------------------------------------------------------------------------------- 1 | // Initial Bulma Constants 2 | @import "~@lolpants/bulma/sass/utilities/_all"; 3 | 4 | // Override Bulma variables 5 | $bg: #232325; 6 | $tex: #eff1f5; 7 | $btn: #36373a; 8 | $main: #6782ff; 9 | 10 | // Alternate Colours 11 | $alt: $main; 12 | $altDark: darken($alt, 10); 13 | 14 | // Override Computed 15 | $primary: $main; 16 | $primary-invert: findColorInvert($primary); 17 | $dark: lighten($bg, 8); 18 | $dark-invert: findColorInvert($dark); 19 | 20 | // Setup $colors to use as bulma classes 21 | $colors: ( 22 | "white": ($white, $black), 23 | "black": ($black, $white), 24 | "light": ($light, $light-invert), 25 | "dark": ($dark, $dark-invert), 26 | "primary": ($primary, $primary-invert), 27 | "info": ($info, $info-invert), 28 | "success": ($success, $success-invert), 29 | "warning": ($warning, $warning-invert), 30 | "danger": ($danger, $danger-invert), 31 | ); 32 | 33 | // Body 34 | $body-background-color: $bg; 35 | $body-color: $tex; 36 | $content-heading-color: $tex; 37 | $hr-background-color: rgba(255, 255, 255, 0.2); 38 | $label-color: $tex; 39 | $text-strong: darken($tex, 20); 40 | 41 | // Navbar 42 | $navbar-box-shadow-color: rgba(lighten($dark, 15), 0.2); 43 | $navbar-divider-background-color: darken($btn, 4); 44 | 45 | // Navbar Dropdown 46 | $navbar-dropdown-border-top: 2px solid $navbar-divider-background-color; 47 | $navbar-dropdown-radius: 5px; 48 | $navbar-dropdown-background-color: $btn; 49 | $navbar-dropdown-item-hover-background-color: $bg; 50 | $navbar-dropdown-item-hover-color: $tex; 51 | .navbar-item { 52 | color: $tex !important; 53 | } 54 | 55 | // Links 56 | $link: $alt; 57 | $link-hover: $altDark; 58 | 59 | // Buttons 60 | $button-color: $tex; 61 | $button-hover-color: $tex; 62 | $button-active-color: darken($tex, 20); 63 | $button-focus-color: darken($tex, 20); 64 | $button-background-color: $btn; 65 | $button-border-width: 0; 66 | $button-disabled-background-color: $btn; 67 | .button:hover { 68 | background-color: darken($btn, 2); 69 | } 70 | .button[disabled]:active, fieldset[disabled] .button:active { 71 | color: $tex; 72 | } 73 | 74 | // Text Boxes 75 | $input-color: $tex; 76 | $input-background-color: darken($bg, 5); 77 | $input-border-color: lighten($bg, 4); 78 | $input-hover-border-color: lighten($bg, 8); 79 | 80 | // Tables 81 | $table-color: $tex; 82 | $table-cell-border: 1px solid lighten($bg, 10); 83 | $table-background-color: transparent; 84 | $table-cell-heading-color: darken($tex, 10); 85 | $table-head-cell-color: darken($tex, 10); 86 | $table-foot-cell-color: darken($tex, 10); 87 | 88 | // Spaced List 89 | ul.spaced { 90 | padding: 0; 91 | list-style-type: none; 92 | display: inline; 93 | 94 | & > li { 95 | display: inline; 96 | 97 | &:not(:last-child)::after { 98 | content: ' • '; 99 | color: rgba($tex, 0.5); 100 | } 101 | } 102 | } 103 | 104 | // The rest of Bulma 105 | @import "~@lolpants/bulma"; 106 | -------------------------------------------------------------------------------- /server/src/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import signale, { panic } from '~utils/signale' 3 | 4 | dotenv.config() 5 | const { NODE_ENV } = process.env 6 | 7 | const required = ['JWT_SECRET', 'STEAM_API_KEY', 'MAIL_FROM'] 8 | 9 | try { 10 | for (const variable of required) { 11 | if (!process.env[variable]) throw new Error(variable) 12 | } 13 | } catch (err) { 14 | panic(`Missing environment variable ${err.message}`) 15 | } 16 | 17 | export const JWT_SECRET = process.env.JWT_SECRET as string 18 | if (JWT_SECRET.length < 32) { 19 | signale.warn('JWT Secret does not meet security recommendations!') 20 | } 21 | 22 | export const STEAM_API_KEY = process.env.STEAM_API_KEY as string 23 | 24 | const IS_PROD = 25 | NODE_ENV !== undefined && NODE_ENV.toLowerCase() === 'production' 26 | export const IS_DEV = !IS_PROD 27 | 28 | const dbName = 'beatsaver' 29 | export const MONGO_URL = 30 | process.env.MONGO_URL || IS_DEV 31 | ? `mongodb://localhost:27017/${dbName}` 32 | : `mongodb://mongo:27017/${dbName}` 33 | 34 | const defaultPort = 3000 35 | export const PORT = 36 | parseInt(process.env.PORT || `${defaultPort}`, 10) || defaultPort 37 | 38 | const defaultRounds = 12 39 | export const BCRYPT_ROUNDS = 40 | parseInt(process.env.BCRYPT_ROUNDS || `${defaultRounds}`, 10) || defaultRounds 41 | 42 | const defaultResultsPerPage = 10 43 | export const RESULTS_PER_PAGE = 44 | parseInt(process.env.RESULTS_PER_PAGE || `${defaultResultsPerPage}`, 10) || 45 | defaultResultsPerPage 46 | 47 | export const CACHE_DRIVER = (process.env.CACHE_DRIVER || 'none') as 48 | | 'redis' 49 | | 'none' 50 | 51 | if (CACHE_DRIVER === 'none') { 52 | signale.warn( 53 | 'Route caching is disabled! This is not recommended for production.' 54 | ) 55 | } 56 | 57 | const redisPort = 6379 58 | export const REDIS_HOST = 59 | process.env.REDIS_HOST || (IS_DEV ? 'localhost' : 'redis') 60 | 61 | export const REDIS_PASSWORD = process.env.REDIS_PASSWORD 62 | export const REDIS_PORT = 63 | parseInt(process.env.REDIS_PORT || `${redisPort}`, 10) || redisPort 64 | 65 | const elasticPort = 9200 66 | export const ELASTIC_DISABLED = process.env.ELASTIC_DISABLED === 'true' 67 | export const ELASTIC_HOST = 68 | process.env.ELASTIC_HOST || (IS_DEV ? 'localhost' : 'elastic') 69 | 70 | export const ELASTIC_PORT = 71 | parseInt(process.env.ELASTIC_PORT || `${elasticPort}`, 10) || elasticPort 72 | 73 | export type MailDriver = 'sendgrid' | 'log' 74 | export const MAIL_DRIVER = (process.env.MAIL_DRIVER || 'log') as MailDriver 75 | if (MAIL_DRIVER === 'log') { 76 | signale.warn( 77 | 'Mail driver is set to logs only! This is not recommended for production.' 78 | ) 79 | } 80 | 81 | export const MAIL_FROM = process.env.MAIL_FROM as string 82 | export const SENDGRID_KEY = process.env.SENDGRID_KEY as string 83 | 84 | try { 85 | const sendgridVars = ['SENDGRID_KEY'] 86 | 87 | for (const variable of sendgridVars) { 88 | if (MAIL_DRIVER === 'log') break 89 | if (!process.env[variable]) throw new Error(variable) 90 | } 91 | } catch (err) { 92 | panic(`Missing environment variable ${err.message}`) 93 | } 94 | 95 | export const DISABLE_DUMPS: boolean = 96 | process.env.DISABLE_DUMPS === 'true' || false 97 | -------------------------------------------------------------------------------- /client/src/ts/components/Beatmap/Statistics.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useEffect, useState } from 'react' 2 | import { formatDate } from '../../utils/formatDate' 3 | import { Statistic } from './Statistic' 4 | 5 | interface IStatsProps { 6 | map: IMapStats 7 | uploaded: IBeatmap['uploaded'] 8 | duration: IBeatmap['metadata']['duration'] 9 | } 10 | 11 | interface IFullProps { 12 | map: IBeatmap 13 | uploaded?: undefined 14 | duration?: undefined 15 | } 16 | 17 | interface ICommonProps { 18 | hideTime?: boolean 19 | } 20 | 21 | type IProps = (IStatsProps | IFullProps) & ICommonProps 22 | export const BeatmapStats: FunctionComponent = ({ 23 | map, 24 | uploaded: uploadedRaw, 25 | hideTime, 26 | duration: durationRaw, 27 | }) => { 28 | const uploaded = isFullMap(map) ? map.uploaded : uploadedRaw 29 | if (uploaded === undefined) throw new Error('Uploaded cannot be null!') 30 | const duration = isFullMap(map) ? map.metadata.duration : durationRaw 31 | 32 | const [dateStr, setDateStr] = useState(formatDate(uploaded)) 33 | useEffect(() => { 34 | const i = setInterval(() => { 35 | const newStr = formatDate(uploaded) 36 | if (dateStr !== newStr) setDateStr(newStr) 37 | }, 1000 * 30) 38 | 39 | return () => clearInterval(i) 40 | }, []) 41 | 42 | return ( 43 |

      44 | 45 | 46 | {hideTime ? null : ( 47 | 53 | )} 54 | 55 | 61 | 62 | 68 | 69 | 75 | 76 | 84 | 85 | {duration && duration > 0 ? ( 86 | 92 | ) : null} 93 |
    94 | ) 95 | } 96 | 97 | // @ts-ignore 98 | const isFullMap: (map: IMapStats | IBeatmap) => map is IBeatmap = map => { 99 | return (map as IBeatmap).downloadURL !== undefined 100 | } 101 | 102 | const convertSecondsToTime: (duration: number) => string = duration => { 103 | const hours = Math.trunc(duration / 3600) 104 | const minutes = Math.trunc((duration % 3600) / 60) 105 | const seconds = Math.trunc(duration % 60) 106 | 107 | const HH = hours.toString().padStart(2, '0') 108 | const MM = minutes.toString().padStart(2, '0') 109 | const SS = seconds.toString().padStart(2, '0') 110 | 111 | return hours > 0 ? `${HH}:${MM}:${SS}` : `${MM}:${SS}` 112 | } 113 | -------------------------------------------------------------------------------- /server/src/tasks/dumps.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto' 2 | import { createWriteStream } from 'fs' 3 | import { Document, DocumentQuery } from 'mongoose' 4 | import { schedule } from 'node-cron' 5 | import { join } from 'path' 6 | import { createGzip } from 'zlib' 7 | import { DUMP_PATH } from '~constants' 8 | import { DISABLE_DUMPS } from '~environment' 9 | import { Beatmap, User } from '~mongo/models' 10 | import { exists, globStats, mkdirp, rename, rimraf } from '~utils/fs' 11 | import { waitForMS } from '~utils/misc' 12 | import signale from '~utils/signale' 13 | import { jsonStream } from '~utils/streams' 14 | 15 | const writeDump: ( 16 | path: string, 17 | query: DocumentQuery 18 | ) => Promise = async (name, query) => { 19 | await mkdirp(DUMP_PATH) 20 | 21 | const filePath = join(DUMP_PATH, `${name}.temp.json`) 22 | const gzPath = join(DUMP_PATH, `${name}.temp.json.gz`) 23 | 24 | if (await exists(filePath)) return 25 | if (await exists(gzPath)) return 26 | 27 | const hash = createHash('sha1') 28 | const fileStream = createWriteStream(filePath) 29 | const gzStream = createWriteStream(gzPath) 30 | 31 | const source = query.cursor().pipe(jsonStream()) 32 | source.on('data', chunk => hash.update(chunk)) 33 | 34 | const file = source.pipe(fileStream) 35 | const zip = source.pipe(createGzip()).pipe(gzStream) 36 | 37 | const [sha1] = await Promise.all([ 38 | new Promise(resolve => 39 | source.on('end', () => resolve(hash.digest('hex'))) 40 | ) as Promise, 41 | new Promise(resolve => file.on('close', () => resolve())), 42 | new Promise(resolve => zip.on('close', () => resolve())), 43 | ]) 44 | 45 | const shortHash = (sha1 as string).substr(0, 6) 46 | await rename(filePath, join(DUMP_PATH, `${name}.${shortHash}.json`)) 47 | await rename(gzPath, join(DUMP_PATH, `${name}.${shortHash}.json.gz`)) 48 | 49 | const oneDay = 1000 * 60 * 60 * 24 50 | const now = Date.now() 51 | const cleanup = await globStats([`${name}.*`, `!${name}.temp.*`], { 52 | cwd: DUMP_PATH, 53 | }) 54 | 55 | await Promise.all( 56 | cleanup 57 | .filter(x => now - x.mtimeMs > oneDay * 5) 58 | .map(x => x.absolute) 59 | .map(path => rimraf(path)) 60 | ) 61 | } 62 | 63 | const calculateDelay = () => { 64 | const hostname: string = process.env.HOSTNAME || 'ff' 65 | const shortHost = hostname.substr(0, 2) 66 | const hostDec = parseInt(shortHost, 16) || 255 67 | 68 | const random = Math.floor(Math.random() * 10000) 69 | return hostDec * 20 + random 70 | } 71 | 72 | const dumpTask = async () => { 73 | const delay = Math.max(0, calculateDelay()) 74 | await waitForMS(delay) 75 | 76 | signale.start('Creating JSON dumps!') 77 | await Promise.all([ 78 | writeDump( 79 | 'maps', 80 | Beatmap.find({}, '-votes') 81 | .sort({ uploaded: -1 }) 82 | .populate('uploader') 83 | ), 84 | writeDump( 85 | 'users', 86 | User.find({}, '-password -email -verified -verifyToken -admin') 87 | ), 88 | ]) 89 | 90 | signale.complete('JSON dumps written!') 91 | } 92 | 93 | if (DISABLE_DUMPS) { 94 | signale.warn('Nightly dumps are disabled!') 95 | } else { 96 | signale.pending('Starting JSON dump task...') 97 | dumpTask() 98 | schedule('0 */12 * * *', async () => dumpTask()) 99 | } 100 | -------------------------------------------------------------------------------- /server/src/routes/upload/index.ts: -------------------------------------------------------------------------------- 1 | import pTimeout from '@lolpants/ptimeout' 2 | import fileType from 'file-type' 3 | import multer, { File, MulterIncomingMessage } from 'koa-multer' 4 | import passport from 'koa-passport' 5 | import Router from 'koa-router' 6 | import { MongoError } from 'mongodb' 7 | import { join } from 'path' 8 | import { CDN_PATH } from '~constants' 9 | import { clearCache, rateLimit } from '~middleware' 10 | import { Beatmap, IUserModel } from '~mongo/models' 11 | import { mkdirp, writeFile } from '~utils/fs' 12 | import signale from '~utils/signale' 13 | import { parseBeatmap } from './parseBeatmap' 14 | 15 | import { 16 | ERR_BEATMAP_NOT_ZIP, 17 | ERR_BEATMAP_PARSE_TIMEOUT, 18 | ERR_BEATMAP_SAVE_FAILURE, 19 | ERR_DUPLICATE_BEATMAP, 20 | ERR_NO_BEATMAP, 21 | ERR_UNKNOWN_BEATMAP, 22 | } from './errors' 23 | 24 | const upload = multer({ 25 | limits: { fileSize: 1000 * 1000 * 15 }, 26 | storage: multer.memoryStorage(), 27 | }) 28 | 29 | const router = new Router({ 30 | prefix: '/upload', 31 | }) 32 | .use(rateLimit(10 * 60 * 1000, 10)) 33 | .use(upload.any()) 34 | 35 | router.post( 36 | '/', 37 | passport.authenticate('jwt', { session: false }), 38 | async ctx => { 39 | const user: IUserModel = ctx.state.user 40 | if (!user.verified) return (ctx.status = 403) 41 | 42 | const { files: f, body } = ctx.req as MulterIncomingMessage 43 | const { name, description } = body || ({} as any) 44 | const files = f as File[] 45 | 46 | const beatmapFile = (files || []).find(x => x.fieldname === 'beatmap') 47 | if (beatmapFile === undefined) { 48 | throw ERR_NO_BEATMAP 49 | } 50 | 51 | const type = fileType(beatmapFile.buffer) 52 | if (type === undefined) { 53 | throw ERR_UNKNOWN_BEATMAP 54 | } else if (type.mime !== 'application/zip') { 55 | throw ERR_BEATMAP_NOT_ZIP 56 | } 57 | 58 | const { parsed: beatmap, cover, zip } = await pTimeout( 59 | () => parseBeatmap(beatmapFile.buffer), 60 | 1000 * 10, 61 | ERR_BEATMAP_PARSE_TIMEOUT 62 | ) 63 | 64 | const [latest] = await Beatmap.find() 65 | .sort({ key: -1 }) 66 | .limit(1) 67 | 68 | try { 69 | const nextKey = ((parseInt(latest && latest.key, 16) || 0) + 1).toString( 70 | 16 71 | ) 72 | 73 | const beatmapDir = join(CDN_PATH, nextKey) 74 | await mkdirp(beatmapDir) 75 | await writeFile(join(beatmapDir, `${beatmap.hash}.zip`), zip) 76 | await writeFile( 77 | join(beatmapDir, `${beatmap.hash}${beatmap.coverExt}`), 78 | cover 79 | ) 80 | 81 | const newBeatmap = await Beatmap.create({ 82 | description, 83 | key: nextKey, 84 | name, 85 | uploader: user._id, 86 | 87 | ...beatmap, 88 | }) 89 | 90 | await Promise.all([ 91 | clearCache('maps'), 92 | clearCache(`uploader:${newBeatmap.uploader}`), 93 | ]) 94 | 95 | await newBeatmap.populate('uploader').execPopulate() 96 | return (ctx.body = newBeatmap) 97 | } catch (err) { 98 | if (err instanceof MongoError || err.name === 'ValidationError') { 99 | if (err.code === 11000) throw ERR_DUPLICATE_BEATMAP 100 | else throw err 101 | } 102 | 103 | signale.error(err) 104 | throw ERR_BEATMAP_SAVE_FAILURE 105 | } 106 | } 107 | ) 108 | 109 | export { router as uploadRouter } 110 | -------------------------------------------------------------------------------- /client/src/ts/routes/Login.tsx: -------------------------------------------------------------------------------- 1 | import { AxiosError } from 'axios' 2 | import clsx from 'clsx' 3 | import { push as pushFn } from 'connected-react-router' 4 | import React, { FunctionComponent, useState } from 'react' 5 | import Helmet from 'react-helmet' 6 | import { connect } from 'react-redux' 7 | import { Link } from 'react-router-dom' 8 | import { IconInput } from '../components/Input' 9 | import { Login, login as loginFn } from '../store/user' 10 | 11 | interface IProps { 12 | login: Login 13 | push: typeof pushFn 14 | } 15 | 16 | const Login: FunctionComponent = ({ login, push }) => { 17 | const [loading, setLoading] = useState(false) 18 | const [username, setUsername] = useState('') 19 | const [password, setPassword] = useState('') 20 | 21 | const [usernameErr, setUsernameErr] = useState(undefined) 22 | const [passwordErr, setPasswordErr] = useState(undefined) 23 | 24 | const submit = async () => { 25 | setLoading(true) 26 | 27 | try { 28 | await login(username, password) 29 | 30 | setLoading(false) 31 | push('/') 32 | } catch (err) { 33 | setLoading(false) 34 | 35 | const { response } = err as AxiosError 36 | if (response === undefined) { 37 | setUsernameErr('Unknown server error!') 38 | return 39 | } 40 | 41 | if (response.status === 400) return setUsernameErr('Invalid username!') 42 | if (response.status === 401) return setPasswordErr('Incorrect password!') 43 | if (response.status === 404) return setUsernameErr('Username not found!') 44 | } 45 | } 46 | 47 | return ( 48 |
    49 | 50 | BeatSaver - Login 51 | 52 | 53 |

    54 | Login 55 |

    56 |
    57 | 58 | { 63 | setUsername(v) 64 | setUsernameErr(undefined) 65 | setPasswordErr(undefined) 66 | }} 67 | onSubmit={() => submit()} 68 | iconClass='fas fa-user' 69 | errorLabel={usernameErr} 70 | /> 71 | 72 | { 78 | setPassword(v) 79 | setUsernameErr(undefined) 80 | setPasswordErr(undefined) 81 | }} 82 | onSubmit={() => submit()} 83 | iconClass='fas fa-lock' 84 | errorLabel={passwordErr} 85 | /> 86 | 87 | 94 | 95 | 100 | Don't have an account? 101 | 102 |
    103 | ) 104 | } 105 | 106 | const mapDispatchToProps: IProps = { 107 | login: loginFn, 108 | push: pushFn, 109 | } 110 | 111 | const ConnectedLogin = connect(undefined, mapDispatchToProps)(Login) 112 | export { ConnectedLogin as Login } 113 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beatsaver/server", 3 | "version": "0.1.0", 4 | "description": "BeatSaver Again", 5 | "main": "build/index.js", 6 | "author": "Jack Baron (https://www.jackbaron.com)", 7 | "license": "ISC", 8 | "private": true, 9 | "engines": { 10 | "node": ">=10.0.0" 11 | }, 12 | "scripts": { 13 | "test": "yarn run check && yarn run lint", 14 | "check": "yarn run compile --noEmit", 15 | "lint": "tslint -c tslint.json 'src/**/*.ts'", 16 | "compile": "ttsc", 17 | "clean": "rimraf build", 18 | "prebuild": "yarn run clean", 19 | "build": "yarn run compile", 20 | "dev": "yarn run build --watch", 21 | "dev:run": "nodemon --inspect . --watch build", 22 | "modclean": "modclean -r" 23 | }, 24 | "devDependencies": { 25 | "@types/bcrypt": "^3.0.0", 26 | "@types/chunk": "^0.0.0", 27 | "@types/dotenv": "^6.1.1", 28 | "@types/ffprobe-static": "^2.0.0", 29 | "@types/image-size": "^0.7.0", 30 | "@types/ioredis": "^4.0.14", 31 | "@types/jszip": "^3.1.6", 32 | "@types/koa": "^2.0.49", 33 | "@types/koa-helmet": "^3.1.2", 34 | "@types/koa-multer": "^1.0.0", 35 | "@types/koa-passport": "^4.0.2", 36 | "@types/koa-ratelimit": "^4.1.1", 37 | "@types/koa-redis-cache": "^3.0.0", 38 | "@types/koa-router": "^7.0.42", 39 | "@types/koa__cors": "^2.2.3", 40 | "@types/mkdirp": "^0.5.2", 41 | "@types/mongoose": "^5.5.23", 42 | "@types/mongoose-paginate-v2": "^1.0.3", 43 | "@types/node": "^12.7.2", 44 | "@types/node-cron": "^2.0.2", 45 | "@types/passport": "^1.0.0", 46 | "@types/passport-jwt": "^3.0.1", 47 | "@types/passport-local": "^1.0.33", 48 | "@types/rimraf": "^2.0.2", 49 | "@types/signale": "^1.2.1", 50 | "@types/tmp": "^0.1.0", 51 | "@types/uuid": "^3.4.5", 52 | "nodemon": "^1.19.1", 53 | "prettier": "^1.19.1", 54 | "tslint": "^5.18.0", 55 | "tslint-config-prettier": "^1.18.0", 56 | "tslint-microsoft-contrib": "^6.2.0", 57 | "tslint-plugin-prettier": "^2.0.1", 58 | "ttypescript": "^1.5.8", 59 | "typescript": "^3.7.2", 60 | "typescript-transform-paths": "^1.1.11" 61 | }, 62 | "dependencies": { 63 | "@koa/cors": "^3.0.0", 64 | "@lolpants/ptimeout": "^0.1.0", 65 | "@sendgrid/mail": "^6.4.0", 66 | "ajv": "^6.10.2", 67 | "axios": "^0.19.0", 68 | "bcrypt": "^5.0.0", 69 | "bson-ext": "^2.0.3", 70 | "chunk": "^0.0.2", 71 | "dotenv": "^8.0.0", 72 | "execa": "^3.3.0", 73 | "ffprobe-static": "^3.0.0", 74 | "file-type": "^12.1.0", 75 | "globby": "^10.0.1", 76 | "image-size": "^0.7.4", 77 | "ioredis": "^4.14.0", 78 | "jsonwebtoken": "^8.5.1", 79 | "jszip": "^3.2.2", 80 | "koa": "^2.7.0", 81 | "koa-body": "^4.1.1", 82 | "koa-helmet": "^5.0.0", 83 | "koa-multer": "^1.0.2", 84 | "koa-passport": "^4.1.3", 85 | "koa-ratelimit": "^4.2.0", 86 | "koa-redis-cache": "^3.0.2", 87 | "koa-router": "^7.4.0", 88 | "mkdirp": "^0.5.1", 89 | "modclean": "^3.0.0-beta.1", 90 | "mongoosastic": "^4.5.0", 91 | "mongoose": "^5.7.7", 92 | "mongoose-paginate-v2": "^1.3.0", 93 | "node-cron": "^2.0.3", 94 | "passport": "^0.4.0", 95 | "passport-jwt": "^4.0.0", 96 | "passport-local": "^1.0.0", 97 | "read-pkg-up": "^6.0.0", 98 | "rimraf": "^3.0.0", 99 | "signale": "^1.4.0", 100 | "source-map-support": "^0.5.13", 101 | "static-koa-router": "^1.0.3", 102 | "tmp-promise": "^2.0.2", 103 | "tslib": "^1.10.0", 104 | "uuid": "^3.3.2" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /server/src/routes/upload/errors.ts: -------------------------------------------------------------------------------- 1 | import CodedError from '~utils/CodedError' 2 | 3 | export const ERR_NO_BEATMAP = new CodedError( 4 | 'no beatmap uploaded', 5 | 0x30001, 6 | 'ERR_NO_BEATMAP', 7 | 400 8 | ) 9 | 10 | export const ERR_UNKNOWN_BEATMAP = new CodedError( 11 | 'beatmap file type is unknown', 12 | 0x30002, 13 | 'ERR_UNKNOWN_BEATMAP', 14 | 400 15 | ) 16 | 17 | export const ERR_BEATMAP_NOT_ZIP = new CodedError( 18 | 'beatmap is not a zip', 19 | 0x30003, 20 | 'ERR_BEATMAP_NOT_ZIP', 21 | 400 22 | ) 23 | 24 | export const ERR_DUPLICATE_BEATMAP = new CodedError( 25 | 'beatmap hash already exists', 26 | 0x30004, 27 | 'ERR_DUPLICATE_BEATMAP', 28 | 409 29 | ) 30 | 31 | export const ERR_BEATMAP_SAVE_FAILURE = new CodedError( 32 | 'beatmap failed to save', 33 | 0x30005, 34 | 'ERR_BEATMAP_SAVE_FAILURE', 35 | 500 36 | ) 37 | 38 | export const ERR_BEATMAP_INFO_NOT_FOUND = new CodedError( 39 | 'info.dat not found', 40 | 0x30006, 41 | 'ERR_BEATMAP_INFO_NOT_FOUND', 42 | 400 43 | ) 44 | 45 | export const ERR_BEATMAP_INFO_INVALID = new CodedError( 46 | 'invalid info.dat', 47 | 0x30007, 48 | 'ERR_BEATMAP_INFO_INVALID', 49 | 400 50 | ) 51 | 52 | export const ERR_BEATMAP_DIFF_NOT_FOUND = (filename: string) => 53 | new CodedError( 54 | `${filename} not found`, 55 | 0x30008, 56 | 'ERR_BEATMAP_DIFF_NOT_FOUND', 57 | 400 58 | ) 59 | 60 | export const ERR_BEATMAP_DIFF_INVALID = (filename: string) => 61 | new CodedError( 62 | `${filename} is invalid`, 63 | 0x30012, 64 | 'ERR_BEATMAP_DIFF_INVALID', 65 | 400 66 | ) 67 | 68 | export const ERR_BEATMAP_COVER_NOT_FOUND = (filename: string) => 69 | new CodedError( 70 | `${filename} not found`, 71 | 0x30009, 72 | 'ERR_BEATMAP_COVER_NOT_FOUND', 73 | 400 74 | ) 75 | 76 | export const ERR_BEATMAP_COVER_INVALID = new CodedError( 77 | 'beatmap cover image invalid', 78 | 0x3000a, 79 | 'ERR_BEATMAP_COVER_INVALID', 80 | 400 81 | ) 82 | 83 | export const ERR_BEATMAP_COVER_NOT_SQUARE = new CodedError( 84 | 'beatmap cover image not a square', 85 | 0x3000b, 86 | 'ERR_BEATMAP_COVER_NOT_SQUARE', 87 | 400 88 | ) 89 | 90 | export const ERR_BEATMAP_COVER_TOO_SMOL = new CodedError( 91 | 'beatmap cover image is too smol', 92 | 0x3000c, 93 | 'ERR_BEATMAP_COVER_TOO_SMOL', 94 | 400 95 | ) 96 | 97 | export const ERR_BEATMAP_AUDIO_NOT_FOUND = (filename: string) => 98 | new CodedError( 99 | `${filename} not found`, 100 | 0x3000d, 101 | 'ERR_BEATMAP_AUDIO_NOT_FOUND', 102 | 400 103 | ) 104 | 105 | export const ERR_BEATMAP_AUDIO_INVALID = new CodedError( 106 | 'beatmap audio file invalid', 107 | 0x3000e, 108 | 'ERR_BEATMAP_AUDIO_INVALID', 109 | 400 110 | ) 111 | 112 | export const ERR_BEATMAP_AUDIO_READ_FAILURE = new CodedError( 113 | 'failed to read beatmap audio', 114 | 0x30013, 115 | 'ERR_BEATMAP_AUDIO_READ_FAILURE', 116 | 500 117 | ) 118 | 119 | export const ERR_BEATMAP_CONTAINS_ILLEGAL_FILE = (filename: string) => { 120 | const err = new CodedError( 121 | 'illegal file in zip', 122 | 0x3000f, 123 | 'ERR_BEATMAP_CONTAINS_ILLEGAL_FILE', 124 | 400 125 | ) 126 | 127 | err.ext.filename = filename 128 | return err 129 | } 130 | 131 | export const ERR_BEATMAP_CONTAINS_AUTOSAVES = new CodedError( 132 | 'beatmap zip contains autosaves', 133 | 0x30010, 134 | 'ERR_BEATMAP_CONTAINS_AUTOSAVES', 135 | 400 136 | ) 137 | 138 | export const ERR_BEATMAP_PARSE_TIMEOUT = new CodedError( 139 | 'beatmap parse timeout', 140 | 0x30011, 141 | 'ERR_BEATMAP_PARSE_TIMEOUT', 142 | 408 143 | ) 144 | -------------------------------------------------------------------------------- /client/src/sass/_beatmap-result.scss: -------------------------------------------------------------------------------- 1 | $margin: 14px; 2 | $image-size: 190px; 3 | 4 | div.beatmap-result { 5 | $hide-cover: 750px; 6 | 7 | $radius: 8px; 8 | margin-bottom: $margin; 9 | border-radius: $radius; 10 | display: flex; 11 | 12 | $transition-time: 120ms; 13 | box-shadow: rgba(0, 0, 0, 0.4) 0 4px 6px 0px; 14 | transition: transform $transition-time ease, box-shadow $transition-time ease; 15 | 16 | &:hover { 17 | transform: translateY(-2px); 18 | box-shadow: rgba(0, 0, 0, 0.6) 0 6px 10px 0px; 19 | } 20 | 21 | & div.cover { 22 | border-radius: $radius 0 0 $radius; 23 | width: $image-size; 24 | height: $image-size; 25 | overflow: hidden; 26 | 27 | @media screen and (max-width: $hide-cover) { 28 | width: 0; 29 | } 30 | 31 | & img { 32 | width: $image-size; 33 | height: $image-size; 34 | object-fit: cover; 35 | 36 | border-radius: $radius 0 0 $radius; 37 | } 38 | } 39 | 40 | & div.beatmap-content { 41 | $content-padding: 8px; 42 | 43 | flex: 1; 44 | border-radius: 0 $radius $radius 0; 45 | background-color: rgba(255, 255, 255, 0.92); 46 | padding: $content-padding; 47 | 48 | @media screen and (max-width: $hide-cover) { 49 | border-radius: $radius; 50 | } 51 | 52 | display: flex; 53 | 54 | & .right { 55 | display: flex; 56 | flex-direction: column; 57 | 58 | & .stats { 59 | flex: 1; 60 | 61 | text-align: right; 62 | color: rgb(23, 23, 24); 63 | } 64 | 65 | & .is-button-group { 66 | margin: -3px -8px; 67 | } 68 | 69 | & a, button { 70 | color: $text; 71 | padding: 6px 18px; 72 | 73 | position: relative; 74 | margin-right: -5; 75 | 76 | $border: 1px solid rgba(0, 0, 0, .1); 77 | border-top: $border; 78 | border-left: $border; 79 | 80 | background-color: rgba(255, 255, 255, .4); 81 | transition: background-color 100ms ease; 82 | 83 | &:last-child { 84 | border-radius: 0 0 $radius 0; 85 | } 86 | 87 | &:hover { 88 | background-color: rgba(0, 0, 0, .05); 89 | } 90 | 91 | &:active { 92 | background-color: rgba(0, 0, 0, .1); 93 | } 94 | 95 | &.loading { 96 | color: transparent; 97 | 98 | &::after { 99 | content: ''; 100 | position: absolute; 101 | left: calc(50% - (1em / 2)); 102 | top: calc(50% - (1em / 2)); 103 | 104 | display: block; 105 | width: 1em; 106 | height: 1em; 107 | 108 | border: 2px solid $text; 109 | border-radius: 100%; 110 | border-right-color: transparent; 111 | border-top-color: transparent; 112 | 113 | animation: spinAround .5s infinite linear; 114 | } 115 | } 116 | 117 | &.disabled { 118 | pointer-events: none; 119 | } 120 | } 121 | } 122 | 123 | & .outer { 124 | flex: 1; 125 | 126 | display: flex; 127 | flex-direction: column; 128 | 129 | & div.details { 130 | flex-grow: 1; 131 | margin-bottom: $content-padding; 132 | 133 | color: rgb(23, 23, 24); 134 | 135 | & h1, h2 { 136 | line-height: 1em; 137 | } 138 | 139 | & h1 { 140 | font-size: 1.8rem; 141 | } 142 | 143 | & h2 { 144 | font-size: 1.15rem; 145 | margin-top: 6px; 146 | 147 | & > span.uploaded { 148 | color: rgba(0, 0, 0, 0.5); 149 | font-size: 0.7em; 150 | } 151 | } 152 | 153 | @media screen and (max-width: $hide-cover) { 154 | & h1 { 155 | font-size: 1.6rem; 156 | } 157 | 158 | & h2 { 159 | font-size: 1rem !important; 160 | } 161 | } 162 | } 163 | } 164 | } 165 | } 166 | 167 | div.beatmap-result-hidden { 168 | height: $margin + $image-size; 169 | } 170 | -------------------------------------------------------------------------------- /client/src/ts/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import React, { FunctionComponent, KeyboardEvent } from 'react' 3 | 4 | interface IProps { 5 | value: string 6 | type?: 'text' | 'password' | 'email' | 'tel' 7 | placeholder?: string 8 | maxLength?: number 9 | autoFocus?: boolean 10 | disabled?: boolean 11 | readOnly?: boolean 12 | autoComplete?: 'on' | 'off' | 'username' | 'current-password' | 'new-password' 13 | autoCapitalize?: 'on' | 'off' 14 | 15 | label?: string 16 | errorLabel?: string 17 | 18 | size?: 'small' | 'normal' | 'medium' | 'large' 19 | style?: 'primary' | 'info' | 'success' | 'warning' | 'danger' 20 | rounded?: boolean 21 | 22 | onChange: (value: string) => any 23 | onSubmit?: (e?: KeyboardEvent) => any 24 | } 25 | 26 | const RawInput: FunctionComponent = ({ 27 | type, 28 | value, 29 | placeholder, 30 | maxLength, 31 | autoFocus, 32 | disabled, 33 | readOnly, 34 | autoComplete, 35 | autoCapitalize, 36 | 37 | size, 38 | style, 39 | rounded, 40 | 41 | onChange, 42 | onSubmit, 43 | }) => ( 44 | { 55 | if (typeof onChange !== 'function') return false 56 | if (maxLength && e.target.value.length > maxLength) return false 57 | 58 | return onChange(e.target.value) 59 | }} 60 | autoFocus={autoFocus} 61 | disabled={disabled} 62 | readOnly={readOnly} 63 | autoComplete={autoComplete} 64 | autoCapitalize={autoCapitalize} 65 | onKeyPress={e => { 66 | if (e.key === 'Enter' && typeof onSubmit === 'function') { 67 | onSubmit() 68 | } 69 | }} 70 | /> 71 | ) 72 | 73 | interface ILabelProps { 74 | label?: string 75 | errorLabel?: string 76 | } 77 | 78 | const RawLabel: FunctionComponent = ({ label, errorLabel }) => 79 | label === undefined && errorLabel === undefined ? null : ( 80 | 86 | ) 87 | 88 | export const Input: FunctionComponent = props => ( 89 |
    90 | 91 | 92 |
    93 | 94 |
    95 |
    96 | ) 97 | 98 | interface IIconProps { 99 | iconClass: string 100 | } 101 | 102 | export const IconInput: FunctionComponent = props => ( 103 |
    104 | 105 | 106 |
    107 | 108 | 109 | 110 | 111 | 112 |
    113 |
    114 | ) 115 | 116 | interface ITextareaProps extends Omit { 117 | rows?: number 118 | maxLength?: number 119 | fixed?: boolean 120 | } 121 | 122 | export const TextareaInput: FunctionComponent = props => ( 123 |
    124 | 125 | 126 |