diff --git a/Server/Source/Modules/Middleware.ts b/Server/Source/Modules/Middleware.ts index aceb607..c7f51bc 100644 --- a/Server/Source/Modules/Middleware.ts +++ b/Server/Source/Modules/Middleware.ts @@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from "express"; import { User } from "../Schemas/User"; import { JwtPayload, verify } from "jsonwebtoken"; import { IS_DEBUG, JWT_KEY } from "./Constants"; +import j from "joi"; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -13,7 +14,7 @@ declare global { } export function RequireAuthentication(Relations?: object) { - return async (req: Request, res: Response, next: NextFunction) =>{ + return async (req: Request, res: Response, next: NextFunction) => { if (!req.header("X-Partypack-Token") && !req.cookies["Token"] && !req.header("Authorization")) return res.status(401).json({ errorMessage: "This endpoint requires authorization." }); @@ -32,4 +33,15 @@ export function RequireAuthentication(Relations?: object) { req.user = UserData; next(); } +} + +export function ValidateBody(Schema: j.Schema) { + return async (req: Request, res: Response, next: NextFunction) => { + try { + req.body = await Schema.validateAsync(req.body); + next(); + } catch (err) { + res.status(400).json({ errorMessage: "Body validation failed.", details: err }) + } + } } \ No newline at end of file diff --git a/Server/Source/Routes/Admin.ts b/Server/Source/Routes/Admin.ts index d2c691a..9390c59 100644 --- a/Server/Source/Routes/Admin.ts +++ b/Server/Source/Routes/Admin.ts @@ -1,6 +1,10 @@ import { Router } from "express"; -import { ADMIN_KEY } from "../Modules/Constants"; +import { ADMIN_KEY, FULL_SERVER_ROOT } from "../Modules/Constants"; import { Song } from "../Schemas/Song"; +import { ValidateBody } from "../Modules/Middleware"; +import { writeFileSync } from "fs"; +import exif from "exif-reader"; +import j from "joi"; const App = Router(); @@ -22,7 +26,77 @@ App.get("/test", (_, res) => res.send("Permission check OK")); App.get("/tracks", async (_, res) => res.json((await Song.find()).map(x => x.Package()))); -App.post("/create/song", async (req, res) => res.json(await Song.create(req.body).save())); +App.post("/create/song", +ValidateBody(j.object({ + ID: j.string().uuid(), + Name: j.string().required().min(3).max(64), + Year: j.number().required().min(1).max(2999), + ArtistName: j.string().required().min(1).max(64), + Length: j.number().required().min(1), + Scale: j.string().valid("Minor", "Major").required(), + Key: j.string().valid("A", "Ab", "B", "Bb", "C", "Cb", "D", "Db", "E", "Eb", "F", "Fb", "G", "Gb").required(), + Album: j.string().required(), + GuitarStarterType: j.string().valid("Keytar", "Guitar").required(), + Tempo: j.number().min(20).max(1250).required(), + Midi: j.string().uri(), + Cover: j.string().uri(), + Lipsync: j.string().uri(), + BassDifficulty: j.number().required().min(0).max(7), + GuitarDifficulty: j.number().required().min(0).max(7), + DrumsDifficulty: j.number().required().min(0).max(7), + VocalsDifficulty: j.number().required().min(0).max(7) +})), +async (req, res) => { + res.json(await Song.create(req.body).save()) +}); + +App.post("/upload/midi", +ValidateBody(j.object({ + Data: j.string().hex().required(), + TargetSong: j.string().uuid().required() +})), +async (req, res) => { + const Decoded = Buffer.from(req.body.Data, "hex"); + + if (!Decoded.toString().startsWith("MThd")) + return res.status(400).send("Uploaded MIDI file is not a valid MIDI."); + + if (!await Song.exists({ where: { ID: req.body.TargetSong } })) + return res.status(404).send("The song you're trying to upload a MIDI for does not exist."); + + writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Data.mid`, Decoded); + res.send(`${FULL_SERVER_ROOT}/song/download/${req.body.TargetSong}/midi.mid`); +}); + +App.post("/upload/cover", +ValidateBody(j.object({ + Data: j.string().hex().required(), + TargetSong: j.string().uuid().required() +})), +async (req, res) => { + const Decoded = Buffer.from(req.body.Data, "hex"); + + if (!await Song.exists({ where: { ID: req.body.TargetSong } })) + return res.status(404).send("The song you're trying to upload a cover for does not exist."); + + try { // todo: fix + /*const ImageMetadata = exif(Decoded); + if (!ImageMetadata.Image?.ImageWidth || !ImageMetadata.Image?.ImageLength) + throw new Error("Invalid image file."); + + if (ImageMetadata.Image.ImageWidth !== ImageMetadata.Image.ImageLength) + return res.status(400).send("Image must have a 1:1 ratio."); + + if (ImageMetadata.Image.ImageWidth < 512 || ImageMetadata.Image.ImageWidth > 2048) + return res.status(400).send("Image cannot be smaller than 512 pixels and larger than 2048 pixels.");*/ + } catch (err) { + console.error(err) + return res.status(400).send("Invalid image file."); + } + + writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Cover.png`, Decoded); + res.send(`${FULL_SERVER_ROOT}/song/download/${req.body.TargetSong}/cover.png`); +}); export default { App, diff --git a/Server/Source/Routes/Library.ts b/Server/Source/Routes/Library.ts index 35d3279..f543854 100644 --- a/Server/Source/Routes/Library.ts +++ b/Server/Source/Routes/Library.ts @@ -1,7 +1,8 @@ import { Router } from "express"; -import { RequireAuthentication } from "../Modules/Middleware"; +import { RequireAuthentication, ValidateBody } from "../Modules/Middleware"; import { Song } from "../Schemas/Song"; import { OriginalSparks } from "../Modules/FNUtil"; +import j from "joi"; const App = Router(); @@ -12,13 +13,13 @@ App.get("/me", RequireAuthentication({ BookmarkedSongs: true }), (req, res) => { }) }) -App.post("/me/activate", RequireAuthentication(), async (req, res) => { - if (!req.body.SongID || !req.body.ToOverride) - return res.status(400).json({ errorMessage: "You didn't provide a Song ID." }); - - if (!/^sid_placeholder_(\d){1,3}$/gi.test(req.body.ToOverride)) - return res.status(400).json({ errorMessage: "Field \"ToOverride\" must match \"sid_placeholder_\"" }); - +App.post("/me/activate", +RequireAuthentication(), +ValidateBody(j.object({ + SongID: j.string().uuid().required(), + ToOverride: j.string().pattern(/^sid_placeholder_(\d){1,3}$/i).required() +})), +async (req, res) => { if (req.user?.Library.findIndex(x => x.SongID.toLowerCase() === req.body.SongID.toLowerCase() || x.Overriding.toLowerCase() === req.body.ToOverride.toLowerCase()) !== -1) return res.status(400).json({ errorMessage: "This song is already activated." }); @@ -31,10 +32,12 @@ App.post("/me/activate", RequireAuthentication(), async (req, res) => { res.json(req.user?.Library); }) -App.post("/me/deactivate", RequireAuthentication(), async (req, res) => { - if (!req.body.SongID) - return res.status(400).json({ errorMessage: "You didn't provide a Song ID." }); - +App.post("/me/deactivate", +RequireAuthentication(), +ValidateBody(j.object({ + SongID: j.string().uuid().required() +})), +async (req, res) => { const idx = req.user!.Library.findIndex(x => x.SongID.toLowerCase() === req.body.SongID.toLowerCase()); if (idx === -1) return res.status(400).json({ errorMessage: "This song is not activated." }); @@ -45,10 +48,12 @@ App.post("/me/deactivate", RequireAuthentication(), async (req, res) => { res.json(req.user?.Library); }) -App.post("/me/bookmark", RequireAuthentication({ BookmarkedSongs: true }), async (req, res) => { - if (!req.body.SongID) - return res.status(400).json({ errorMessage: "You didn't provide a Song ID." }); - +App.post("/me/bookmark", +RequireAuthentication({ BookmarkedSongs: true }), +ValidateBody(j.object({ + SongID: j.string().uuid().required() +})), +async (req, res) => { if (req.user?.BookmarkedSongs.findIndex(x => x.ID.toLowerCase() === req.body.SongID.toLowerCase()) !== -1) return res.status(400).json({ errorMessage: "This song is already bookmarked." }); @@ -62,10 +67,12 @@ App.post("/me/bookmark", RequireAuthentication({ BookmarkedSongs: true }), async res.json(req.user?.BookmarkedSongs.map(x => x.Package())); }) -App.post("/me/unbookmark", RequireAuthentication(), async (req, res) => { - if (!req.body.SongID) - return res.status(400).json({ errorMessage: "You didn't provide a Song ID." }); - +App.post("/me/unbookmark", +RequireAuthentication(), +ValidateBody(j.object({ + SongID: j.string().uuid().required() +})), +async (req, res) => { const idx = req.user!.BookmarkedSongs.findIndex(x => x.ID.toLowerCase() === req.body.SongID.toLowerCase()); if (idx === -1) return res.status(400).json({ errorMessage: "This song is not bookmarked." }); diff --git a/Server/Source/Schemas/Rating.ts b/Server/Source/Schemas/Rating.ts new file mode 100644 index 0000000..427f7e9 --- /dev/null +++ b/Server/Source/Schemas/Rating.ts @@ -0,0 +1,18 @@ +import { BaseEntity, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { User } from "./User"; +import { Song } from "./Song"; + +@Entity() +export class Rating extends BaseEntity { + @PrimaryGeneratedColumn("uuid") + ID: string; + + @ManyToOne(() => User, U => U.Ratings) + Author: User; + + @ManyToOne(() => Song, S => S.Ratings) + Rated: Song; + + @Column() + Stars: number; +} \ No newline at end of file diff --git a/Server/Source/Schemas/Song.ts b/Server/Source/Schemas/Song.ts index b1cf97a..978f143 100644 --- a/Server/Source/Schemas/Song.ts +++ b/Server/Source/Schemas/Song.ts @@ -1,5 +1,8 @@ -import { BaseEntity, BeforeInsert, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +import { BaseEntity, BeforeInsert, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; import { FULL_SERVER_ROOT } from "../Modules/Constants"; +import { Rating } from "./Rating"; +import { existsSync, mkdirSync } from "fs"; +import { v4 } from "uuid"; @Entity() export class Song extends BaseEntity { @@ -60,8 +63,16 @@ export class Song extends BaseEntity { @Column({ nullable: true }) Lipsync?: string; + @OneToMany(() => Rating, R => R.Rated) + Ratings: Rating[]; + @BeforeInsert() Setup() { + this.ID = v4(); + this.Directory = `./Saved/Songs/${this.ID}`; + if (!existsSync(this.Directory)) + mkdirSync(this.Directory); + this.CreationDate = new Date(); } diff --git a/Server/Source/Schemas/User.ts b/Server/Source/Schemas/User.ts index ec2e71a..27fa05f 100644 --- a/Server/Source/Schemas/User.ts +++ b/Server/Source/Schemas/User.ts @@ -1,5 +1,6 @@ -import { BaseEntity, Column, Entity, JoinTable, ManyToMany, PrimaryColumn } from "typeorm"; +import { BaseEntity, Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryColumn } from "typeorm"; import { Song } from "./Song"; +import { Rating } from "./Rating"; @Entity() export class User extends BaseEntity { @@ -9,6 +10,9 @@ export class User extends BaseEntity { @Column({ type: "simple-json" }) Library: { SongID: string, Overriding: string }[]; + @OneToMany(() => Rating, R => R.Author) + Ratings: Rating[]; + @ManyToMany(() => Song, { eager: true }) @JoinTable() BookmarkedSongs: Song[]; diff --git a/Server/package-lock.json b/Server/package-lock.json index 5e05acc..c02c86b 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -10,16 +10,20 @@ "license": "ISC", "dependencies": { "@types/cors": "^2.8.17", + "@types/uuid": "^9.0.7", "axios": "^1.6.5", "better-sqlite3": "^9.3.0", "colorette": "^2.0.20", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", + "exif-reader": "^2.0.0", "express": "^4.18.2", + "joi": "^17.12.0", "jsonwebtoken": "^9.0.2", "typeorm": "^0.3.19", - "underscore": "^1.13.6" + "underscore": "^1.13.6", + "uuid": "^9.0.1" }, "devDependencies": { "@types/cookie-parser": "^1.4.6", @@ -31,6 +35,19 @@ "typescript": "^5.2.2" } }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -56,6 +73,24 @@ "node": ">=14" } }, + "node_modules/@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -186,6 +221,11 @@ "integrity": "sha512-HP38xE+GuWGlbSRq9WrZkousaQ7dragtZCruBVMi0oX1migFZavZ3OROKHSkNp/9ouq82zrWtZpg18jFnVN96g==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -852,6 +892,11 @@ "node": ">= 0.6" } }, + "node_modules/exif-reader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/exif-reader/-/exif-reader-2.0.0.tgz", + "integrity": "sha512-/c4JW2Kb4/MdmUJyMKTXhNG524q0pCA1DDCEb2aawd9j2i2+bc+mKGKKyO4z2glRTKW93Ks4BWCgmKeEyKiUeA==" + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1183,6 +1228,18 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/joi": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.12.0.tgz", + "integrity": "sha512-HSLsmSmXz+PV9PYoi3p7cgIbj06WnEBNT28n+bbBNcPZXZFqCzzvGqpTBPujx/Z0nh1+KNQPDrNgdmQ8dq0qYw==", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.4", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", diff --git a/Server/package.json b/Server/package.json index 57df92d..d365bce 100644 --- a/Server/package.json +++ b/Server/package.json @@ -39,16 +39,20 @@ }, "dependencies": { "@types/cors": "^2.8.17", + "@types/uuid": "^9.0.7", "axios": "^1.6.5", "better-sqlite3": "^9.3.0", "colorette": "^2.0.20", "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", + "exif-reader": "^2.0.0", "express": "^4.18.2", + "joi": "^17.12.0", "jsonwebtoken": "^9.0.2", "typeorm": "^0.3.19", - "underscore": "^1.13.6" + "underscore": "^1.13.6", + "uuid": "^9.0.1" }, "homepage": "https://github.com/McMistrzYT/BasedServer#readme" } diff --git a/src/App.tsx b/src/App.tsx index 581ba6c..c0a277d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { ToastContainer } from "react-toastify"; import { BaseStyles, ThemeProvider, theme } from "@primer/react"; import { SiteHeader } from "./components/SiteHeader"; @@ -11,12 +12,12 @@ import { AdminLogin } from "./routes/AdminLogin"; import { Download } from "./routes/Download"; import { Tracks } from "./routes/Tracks"; import { Profile } from "./routes/Profile"; +import { AdminCreateTrack } from "./routes/AdminCreateTrack"; import { SiteContext, SiteState } from "./utils/State"; import merge from "deepmerge"; import "react-toastify/dist/ReactToastify.css"; import "./css/index.css"; -import { useState } from "react"; const DefaultTheme = merge(theme, {}); // we'll use this!! eventually!!! @@ -44,6 +45,7 @@ function App() { } /> } /> {/* this is the only publically available admin endpoint */} } /> + } /> diff --git a/src/routes/AdminCreateTrack.tsx b/src/routes/AdminCreateTrack.tsx new file mode 100644 index 0000000..2d9f77e --- /dev/null +++ b/src/routes/AdminCreateTrack.tsx @@ -0,0 +1,166 @@ +import { ActionList, ActionMenu, FormControl, TextInput, Heading, Button } from "@primer/react"; +import { useRef, useState } from "react"; +import { toast } from "react-toastify"; +import { Buffer } from "buffer/"; +import axios from "axios"; + +const style = { paddingTop: 3 }; + +export function AdminCreateTrack() { + const formRef = useRef(null); + const [Key, setKey] = useState("Select a key..."); + const [Scale, setScale] = useState("Select a scale..."); + const [GuitarStarterType, setGuitarStarterType] = useState("Select the starter type..."); + + return ( + <> + Create a new song +
+ + Song Name + + + + Artist + If there are multiple artists, separate them with a comma. + + + + Album + + + + Release Year + + + + Length + + + + Key + + {Key} + + { + ["A", "Ab", "B", "Bb", "C", "Cb", "D", "Db", "E", "Eb", "F", "Fb", "G", "Gb"].map(x => { + return ( + setKey(x)}> + {x} + + ) + }) + } + + + + + Scale + + {Scale} + + setScale("Minor")}>Minor + setScale("Major")}>Major + + + + + Lead Type + This is defining what lead instrument the song is going to start with. You can change the instrument mid-game with [keytar] and [guitar] text events. + + {GuitarStarterType} + + setGuitarStarterType("Guitar")}>Guitar + setGuitarStarterType("Keytar")}>Keytar + + + + + Tempo + + + + MIDI File + + You can use the #tools-and-resources channel to find useful resources on how to create MIDIs. + + + Cover Image + + Must be a 1:1 ratio. Max: 2048x2048, min: 512x512 + + + Lead Difficulty + + + + Drums Difficulty + + + + Vocals Difficulty + + + + Bass Difficulty + + + +
+ + ) +} \ No newline at end of file diff --git a/src/routes/AdminTrackList.tsx b/src/routes/AdminTrackList.tsx index 463a8b3..86ea5f6 100644 --- a/src/routes/AdminTrackList.tsx +++ b/src/routes/AdminTrackList.tsx @@ -21,7 +21,7 @@ export function AdminTrackList() { return ( <> - All tracks (admin) + All tracks (admin) { tracks.map(x => { diff --git a/src/routes/Profile.tsx b/src/routes/Profile.tsx index 6ac3aa0..ceacd8f 100644 --- a/src/routes/Profile.tsx +++ b/src/routes/Profile.tsx @@ -58,7 +58,7 @@ export function Profile() { setIsActivateDialogOpen(false)} aria-labelledby="header"> Activate song - In order to activate a song for use, you need to sacrifice another song.
Please select a song you own to replace:
+ In order to activate a song for use, you need to sacrifice another song.
Please select a song you own to replace:
Select a song...