From 3ca15c217058950f47e6c2612854ba8311092aea Mon Sep 17 00:00:00 2001 From: mc <56406996+McMistrzYT@users.noreply.github.com> Date: Wed, 31 Jan 2024 00:27:53 +0100 Subject: [PATCH] suprise commit (i literally changed every file) --- Server/.example.env | 1 + Server/Source/Handlers/Server.ts | 4 +- Server/Source/Modules/Constants.ts | 1 + Server/Source/Modules/Middleware.ts | 8 +- Server/Source/Routes/Admin.ts | 120 +----------------- Server/Source/Routes/Authentication.ts | 2 +- Server/Source/Routes/Debug.ts | 44 +++++++ Server/Source/Routes/Downloads.ts | 8 +- Server/Source/Routes/Drafting.ts | 99 +++++++++++++-- Server/Source/Routes/Library.ts | 51 +++++--- Server/Source/Routes/Moderation.ts | 60 +++++++++ Server/Source/Routes/Pages.ts | 2 +- Server/Source/Routes/Ratings.ts | 4 +- Server/Source/Schemas/Song.ts | 24 +++- Server/Source/Schemas/User.ts | 1 + Server/package-lock.json | 29 ++++- Server/package.json | 2 +- src/App.tsx | 8 +- src/components/Song.tsx | 52 +++++++- src/components/VerifyRole.tsx | 17 +++ src/routes/AdminCreateTrack.tsx | 168 ------------------------- src/routes/AdminSubmissions.tsx | 101 +++++++++++---- src/routes/AdminTrackList.tsx | 2 +- src/routes/Profile.tsx | 87 ++++++++++--- src/routes/TrackSubmission.tsx | 9 +- src/utils/Extensions.ts | 18 +++ src/utils/State.ts | 3 +- 27 files changed, 538 insertions(+), 387 deletions(-) create mode 100644 Server/Source/Routes/Debug.ts create mode 100644 Server/Source/Routes/Moderation.ts create mode 100644 src/components/VerifyRole.tsx delete mode 100644 src/routes/AdminCreateTrack.tsx diff --git a/Server/.example.env b/Server/.example.env index c6b325d..0863aef 100644 --- a/Server/.example.env +++ b/Server/.example.env @@ -9,6 +9,7 @@ ADMIN_KEY= BOT_TOKEN= USE_HTTPS=true # set this to false if you're debugging on your computer +MAX_AMOUNT_OF_DRAFTS_AT_ONCE=30 DISCORD_SERVER_ID= DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= \ No newline at end of file diff --git a/Server/Source/Handlers/Server.ts b/Server/Source/Handlers/Server.ts index 2b922df..a29c25d 100644 --- a/Server/Source/Handlers/Server.ts +++ b/Server/Source/Handlers/Server.ts @@ -54,12 +54,12 @@ async function Initialize() { App.use("/assets", e.static("dist/assets")); } - App.use("/api/*", (_, res) => res.status(404).json({ errorMessage: "Not Found" })); + App.use("/api/*", (_, res) => res.status(404).send("Not Found")); App.use("*", (_, res) => res.sendFile(path.join(process.cwd(), "dist", "index.html"))); App.use((err, req, res, _) => { console.error(err); - res.status(500).json({ errorMessage: IS_DEBUG ? err.message : "Oops! Something broke on our end. Sorry!" }); + res.status(500).json(IS_DEBUG ? err.message : "Oops! Something broke on our end. Sorry!"); }) App.listen(PORT, () => Msg(`${magenta(PROJECT_NAME)} now up on port ${magenta(PORT)} ${(IS_DEBUG ? red("(debug environment)") : "")}`)); diff --git a/Server/Source/Modules/Constants.ts b/Server/Source/Modules/Constants.ts index 4b4a0f5..2453a07 100644 --- a/Server/Source/Modules/Constants.ts +++ b/Server/Source/Modules/Constants.ts @@ -17,6 +17,7 @@ export const COOKIE_SIGN_KEY = process.env.COOKIE_SIGN_KEY; // Secret that will export const ADMIN_KEY = process.env.ADMIN_KEY; // Secret that will be required to sign into the Admin Dashboard. export const JWT_KEY = process.env.JWT_KEY; // Secret that will be required to sign JSON Web Tokens (JWTs). export const BOT_TOKEN = process.env.BOT_TOKEN; // Used for Discord-related stuff. +export const MAX_AMOUNT_OF_DRAFTS_AT_ONCE = process.env.MAX_AMOUNT_OF_DRAFTS_AT_ONCE; // A limit for all the drafts you can have. export const DISCORD_SERVER_ID = process.env.DISCORD_SERVER_ID; // Server ID to check roles for permissions in. export const DISCORD_CLIENT_ID = process.env.DISCORD_CLIENT_ID; // Client ID for authentication and checking what role you have on the Discord server. export const DISCORD_CLIENT_SECRET = process.env.DISCORD_CLIENT_SECRET; // Client secret for authentication and checking what role you have on the Discord server. \ No newline at end of file diff --git a/Server/Source/Modules/Middleware.ts b/Server/Source/Modules/Middleware.ts index 8936133..0c6431d 100644 --- a/Server/Source/Modules/Middleware.ts +++ b/Server/Source/Modules/Middleware.ts @@ -16,7 +16,7 @@ declare global { export function RequireAuthentication(Relations?: object) { 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." }); + return res.status(401).send("This endpoint requires authorization."); let JWT: JwtPayload; try { @@ -28,7 +28,7 @@ export function RequireAuthentication(Relations?: object) { const UserData = await User.findOne({ where: { ID: JWT.ID }, relations: Relations }); if (!UserData) - return res.status(401).json({ errorMessage: "Invalid Partypack token provided. User does not exist in database. Please contact an instance admin." }); + return res.status(401).send("Invalid Partypack token provided. User does not exist in database. Please contact an instance admin."); req.user = UserData; next(); @@ -41,7 +41,7 @@ export function ValidateBody(Schema: j.Schema) { req.body = await Schema.validateAsync(req.body); next(); } catch (err) { - res.status(400).json({ errorMessage: "Body validation failed.", details: err }) + res.status(400).json(err) } } } @@ -52,7 +52,7 @@ export function ValidateQuery(Schema: j.Schema) { req.query = await Schema.validateAsync(req.query); next(); } catch (err) { - res.status(400).json({ errorMessage: "Query validation failed.", details: err }) + res.status(400).json(err) } } } \ No newline at end of file diff --git a/Server/Source/Routes/Admin.ts b/Server/Source/Routes/Admin.ts index 0a06281..5a4dc4e 100644 --- a/Server/Source/Routes/Admin.ts +++ b/Server/Source/Routes/Admin.ts @@ -2,7 +2,7 @@ import { FULL_SERVER_ROOT } from "../Modules/Constants"; import { Router } from "express"; import { UserPermissions } from "../Schemas/User"; -import { Song } from "../Schemas/Song"; +import { Song, SongStatus } from "../Schemas/Song"; import { RequireAuthentication, ValidateBody } from "../Modules/Middleware"; import { writeFileSync } from "fs"; import { ForcedCategory } from "../Schemas/ForcedCategory"; @@ -11,6 +11,7 @@ import { Debug } from "../Modules/Logger"; import { magenta } from "colorette"; import ffmpeg from "fluent-ffmpeg"; import j from "joi"; +import sizeOf from "image-size"; const App = Router(); @@ -21,126 +22,13 @@ const App = Router(); App.use(RequireAuthentication()); App.use((req, res, next) => { - const IsAdmin = req.user!.PermissionLevel! >= UserPermissions.Administrator; - if (req.path === "/key") - return res.status(IsAdmin ? 200 : 403).send(IsAdmin ? "Login successful!" : "Key doesn't match. Try again."); - - if (!IsAdmin) + if (req.user!.PermissionLevel! < UserPermissions.Administrator) return res.status(403).send("You don't have permission to access this endpoint."); next(); }); -App.get("/tracks", async (_, res) => res.json((await Song.find()).map(x => x.Package()))); - -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, Draft: false, Author: req.user! }).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 ((await fromBuffer(Decoded))?.ext !== "mid") - 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/audio", - RequireAuthentication(), - ValidateBody(j.object({ - Data: j.string().hex().required(), - TargetSong: j.string().uuid().required() - })), - async (req, res) => { - const Decoded = Buffer.from(req.body.Data, "hex"); - const ext = (await fromBuffer(Decoded))!.ext; - - if (!["mp3", "m4a", "ogg", "wav"].includes(ext)) - return res.status(404).send("Invalid audio file. (supported: mp3, m4a, ogg, wav)"); - - if (!await Song.exists({ where: { ID: req.body.TargetSong } })) - return res.status(404).send("The song you're trying to upload audio for does not exist."); - - await writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`, Decoded); - ffmpeg() - .input(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`) - .outputOptions([ - "-map 0", - "-use_timeline 1", - "-f dash" - ]) - .output(`./Saved/Songs/${req.body.TargetSong}/Chunks/Manifest.mpd`) - .on("start", cl => Debug(`ffmpeg running with ${magenta(cl)}`)) - .on("end", () => Debug("Ffmpeg finished running")) - .on("error", (e, stdout, stderr) => { console.error(e); console.log(stdout); console.error(stderr); }) - .run(); - - res.send("ffmpeg now running on song."); - }); - -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"); - const ext = (await fromBuffer(Decoded))!.ext; - - if (ext !== "png") - return res.status(400).send("Invalid image file. (supported: png)"); - - 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 { - /*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`); -}); +App.get("/tracks", async (_, res) => res.json((await Song.find()).map(x => x.Package(true)))); App.post("/update/discovery", ValidateBody(j.array().items(j.object({ diff --git a/Server/Source/Routes/Authentication.ts b/Server/Source/Routes/Authentication.ts index 3f7c6e0..0e5ea4c 100644 --- a/Server/Source/Routes/Authentication.ts +++ b/Server/Source/Routes/Authentication.ts @@ -62,7 +62,7 @@ async (req, res) => { }).save(); const JWT = jwt.sign({ ID: UserData.data.id }, JWT_KEY!, { algorithm: "HS256" }); - const UserDetails = Buffer.from(JSON.stringify({ ID: UserData.data.id, Username: UserData.data.username, GlobalName: UserData.data.global_name, Avatar: `https://cdn.discordapp.com/avatars/${UserData.data.id}/${UserData.data.avatar}.webp`, IsAdmin: DBUser.PermissionLevel >= UserPermissions.Administrator })).toString("hex") + const UserDetails = Buffer.from(JSON.stringify({ ID: UserData.data.id, Username: UserData.data.username, GlobalName: UserData.data.global_name, Avatar: `https://cdn.discordapp.com/avatars/${UserData.data.id}/${UserData.data.avatar}.webp`, IsAdmin: DBUser.PermissionLevel >= UserPermissions.Administrator, Role: DBUser.PermissionLevel })).toString("hex") if (req.query.state) { try { const Decoded = JSON.parse(Buffer.from(decodeURI(req.query.state as string), "base64").toString("utf-8")); diff --git a/Server/Source/Routes/Debug.ts b/Server/Source/Routes/Debug.ts new file mode 100644 index 0000000..824763b --- /dev/null +++ b/Server/Source/Routes/Debug.ts @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { ENVIRONMENT } from "../Modules/Constants"; +import { RequireAuthentication, ValidateBody } from "../Modules/Middleware"; +import { UserPermissions } from "../Schemas/User"; +import j from "joi"; + +const App = Router(); + +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING +// ! NEVER EVER ENABLE THESE ON PRODUCTION OR STAGING + +App.use((req, res, next) => { + if (ENVIRONMENT !== "dev" && ENVIRONMENT !== "debug") + return res.status(403).send("The current server environment does not allow for debugging endpoints. Switch to `dev` or `debug` to enable them."); + + next(); +}) + +App.post("/update/permissions", +RequireAuthentication(), +ValidateBody(j.object({ + Perms: j.number().valid(...(Object.values(UserPermissions).filter(x => !isNaN(Number(x))))).required() +})), +async (req, res) => { + req.user!.PermissionLevel! = req.body.Perms as UserPermissions; + await req.user!.save(); + res.json(req.user); +}) + +export default { + App, + DefaultAPI: "/api/debug" +} \ No newline at end of file diff --git a/Server/Source/Routes/Downloads.ts b/Server/Source/Routes/Downloads.ts index c2f3b34..09eee32 100644 --- a/Server/Source/Routes/Downloads.ts +++ b/Server/Source/Routes/Downloads.ts @@ -13,12 +13,10 @@ async (req, res) => { //const Song = AvailableFestivalSongs.find(x => x.UUID === req.params.SongUUID); const SongData = await Song.findOne({ where: { ID: req.params.InternalID }, relations: { Author: true } }); if (!SongData) - return res.status(404).json({ errorMessage: "Song not found." }); - - console.log(SongData); + return res.status(404).send("Song not found."); if (SongData.IsDraft && SongData.Author.ID !== req.user!.ID) - return res.status(403).json({ errorMessage: "You cannot use this track, because it's a draft." }); + return res.status(403).send("You cannot use this track, because it's a draft."); const BaseURL = `${FULL_SERVER_ROOT}/song/download/${SongData.ID}/`; switch (req.params.File.toLowerCase()) { @@ -77,7 +75,7 @@ App.get("/:InternalID", async (req, res, next) => { return next(); // trust me bro if (SongData.IsDraft && SongData.Author.ID !== req.user!.ID) - return res.status(403).json({ errorMessage: "You cannot use this track, because it's a draft." }); + return res.status(403).send("You cannot use this track, because it's a draft."); const BaseURL = `${FULL_SERVER_ROOT}/song/download/${SongData.ID}/`; res.set("content-type", "application/json"); diff --git a/Server/Source/Routes/Drafting.ts b/Server/Source/Routes/Drafting.ts index d4e24a4..ba45a2b 100644 --- a/Server/Source/Routes/Drafting.ts +++ b/Server/Source/Routes/Drafting.ts @@ -1,20 +1,20 @@ import j from "joi"; -import exif from "exif-reader"; import ffmpeg from "fluent-ffmpeg"; +import sizeOf from "image-size"; import { Router } from "express"; import { RequireAuthentication, ValidateBody } from "../Modules/Middleware"; -import { Song } from "../Schemas/Song"; +import { Song, SongStatus } from "../Schemas/Song"; import { Debug } from "../Modules/Logger"; import { magenta } from "colorette"; import { fromBuffer } from "file-type"; import { rmSync, writeFileSync } from "fs"; -import { FULL_SERVER_ROOT } from "../Modules/Constants"; +import { FULL_SERVER_ROOT, MAX_AMOUNT_OF_DRAFTS_AT_ONCE } from "../Modules/Constants"; import { UserPermissions } from "../Schemas/User"; const App = Router(); App.post("/create", - RequireAuthentication(), + RequireAuthentication({ CreatedTracks: true }), ValidateBody(j.object({ Name: j.string().required().min(3).max(64), Year: j.number().required().min(1).max(2999), @@ -25,15 +25,19 @@ App.post("/create", Album: j.string().required(), GuitarStarterType: j.string().valid("Keytar", "Guitar").required(), Tempo: j.number().min(20).max(1250).required(), - 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) + BassDifficulty: j.number().required().min(0).max(6), + GuitarDifficulty: j.number().required().min(0).max(6), + DrumsDifficulty: j.number().required().min(0).max(6), + VocalsDifficulty: j.number().required().min(0).max(6) })), async (req, res) => { + if (req.user!.CreatedTracks.length > Number(MAX_AMOUNT_OF_DRAFTS_AT_ONCE)) + return res.status(400).send("You ran out of free draft spots. Please delete some first."); + const SongData = await Song.create({ ...req.body, IsDraft: true, + Status: SongStatus.PROCESSING, Author: req.user! }).save(); @@ -62,6 +66,10 @@ App.post("/upload/midi", writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Data.mid`, Decoded); res.send(`${FULL_SERVER_ROOT}/song/download/${req.body.TargetSong}/midi.mid`); + + await SongData.reload(); + SongData.HasMidi = true; + await SongData.save(); }); App.post("/upload/cover", @@ -85,6 +93,15 @@ App.post("/upload/cover", return res.status(403).send("You don't have permission to upload to this song."); try { + const ImageSize = sizeOf(Decoded); + if (!ImageSize.height || !ImageSize.width) + throw new Error("Unknown image size error"); + + if (ImageSize.height !== ImageSize.width) + return res.status(400).send("Image must have a 1:1 ratio."); + + if (ImageSize.width < 512 || ImageSize.width > 2048) + return res.status(400).send("Image cannot be smaller than 512x512 pixels and larger than 2048x2048 pixels."); /*const ImageMetadata = exif(Decoded); if (!ImageMetadata.Image?.ImageWidth || !ImageMetadata.Image?.ImageLength) throw new Error("Invalid image file."); @@ -99,6 +116,10 @@ App.post("/upload/cover", return res.status(400).send("Invalid image file."); } + await SongData.reload(); + SongData.HasCover = true; + await SongData.save(); + writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Cover.png`, Decoded); res.send(`${FULL_SERVER_ROOT}/song/download/${req.body.TargetSong}/cover.png`); }); @@ -123,6 +144,16 @@ App.post("/upload/audio", if (req.user!.PermissionLevel! < UserPermissions.Administrator && SongData.Author.ID !== req.user!.ID) return res.status(403).send("You don't have permission to upload to this song."); + if (SongData.HasAudio) { + if (SongData.Status !== SongStatus.BROKEN && SongData.Status !== SongStatus.DEFAULT && SongData.Status !== SongStatus.DENIED && SongData.Status !== SongStatus.PUBLIC) + return res.status(400).send("You cannot update this song at this moment."); + + rmSync(`./Saved/Songs/${req.body.TargetSong}/Chunks`, { recursive: true }); + SongData.HasAudio = false; + SongData.Status = SongStatus.PROCESSING; + await SongData.save(); + } + await writeFileSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`, Decoded); ffmpeg() .input(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`) @@ -133,21 +164,55 @@ App.post("/upload/audio", ]) .output(`./Saved/Songs/${req.body.TargetSong}/Chunks/Manifest.mpd`) .on("start", cl => Debug(`ffmpeg running with ${magenta(cl)}`)) - .on("end", () => { + .on("end", async () => { Debug("Ffmpeg finished running"); rmSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`); + + await SongData.reload(); + SongData.HasAudio = true; + await SongData.save(); }) - .on("error", (e, stdout, stderr) => { + .on("error", async (e, stdout, stderr) => { console.error(e); console.log(stdout); console.error(stderr); rmSync(`./Saved/Songs/${req.body.TargetSong}/Audio.${ext}`); + + await SongData.reload(); + SongData.Status = SongStatus.BROKEN; + await SongData.save(); }) .run(); res.send("ffmpeg now running on song."); }); +App.post("/delete", + RequireAuthentication(), + ValidateBody(j.object({ + TargetSong: j.string().uuid().required() + })), + async (req, res) => { + const SongData = await Song.findOne({ where: { ID: req.body.TargetSong }, relations: { Author: true } }) + if (!SongData) + return res.status(404).send("The draft you're trying to delete does not exist."); + + const IsAdmin = req.user!.PermissionLevel! >= UserPermissions.Administrator; + if (!IsAdmin) { + if (SongData.Author.ID !== req.user!.ID) + return res.status(403).send("You don't have permission to remove this draft."); + + if (!SongData.IsDraft) + return res.status(400).send("This draft has already been published. You need to contact an admin to delete published drafts."); + + if (SongData.Status !== SongStatus.DEFAULT && SongData.Status !== SongStatus.DENIED && SongData.Status !== SongStatus.BROKEN) + return res.status(400).send("You cannot delete this draft at this moment."); + } + + await SongData.remove(); + res.send("The draft has been deleted."); + }); + App.post("/submit", RequireAuthentication(), ValidateBody(j.object({ @@ -164,11 +229,19 @@ App.post("/submit", if (!SongData.IsDraft) return res.status(400).send("This song has already been approved and published."); - if (SongData.DraftAwaitingReview) - return res.status(400).send("You already submitted this song for review."); + if (SongData.Status === SongStatus.ACCEPTED) { + SongData.Status = SongStatus.PUBLIC; + SongData.IsDraft = false; + await SongData.save(); + return res.send("Song has been published successfully."); + } + + if (SongData.Status !== SongStatus.DEFAULT) + return res.status(400).send("You cannot submit this song for review at this time."); + + SongData.Status = req.user!.PermissionLevel! >= UserPermissions.VerifiedUser ? SongStatus.ACCEPTED : SongStatus.AWAITING_REVIEW; SongData.DraftReviewSubmittedAt = new Date(); - SongData.DraftAwaitingReview = true; await SongData.save(); return res.send("Song has been submitted for approval by admins."); diff --git a/Server/Source/Routes/Library.ts b/Server/Source/Routes/Library.ts index ef5f43a..ff60360 100644 --- a/Server/Source/Routes/Library.ts +++ b/Server/Source/Routes/Library.ts @@ -1,17 +1,28 @@ import { Router } from "express"; import { RequireAuthentication, ValidateBody } from "../Modules/Middleware"; -import { Song } from "../Schemas/Song"; +import { Song, SongStatus } from "../Schemas/Song"; import { OriginalSparks } from "../Modules/FNUtil"; import j from "joi"; import { UserPermissions } from "../Schemas/User"; const App = Router(); -App.get("/me", RequireAuthentication({ BookmarkedSongs: true, CreatedTracks: true }), (req, res) => { +App.get("/me", RequireAuthentication({ BookmarkedSongs: true, CreatedTracks: true }), async (req, res) => { + const ProcessingTracks = req.user!.CreatedTracks.filter(x => x.Status === SongStatus.PROCESSING); + if (ProcessingTracks.length > 0) + for (const Track of ProcessingTracks) { + console.log(Track.HasAudio, Track.HasMidi, Track.HasCover) + if (!Track.HasAudio || !Track.HasMidi || !Track.HasCover) + continue; + + Track.Status = SongStatus.DEFAULT; + await Track.save(); + } + res.json({ - Bookmarks: req.user?.BookmarkedSongs.map(x => x.Package()), - Created: req.user?.CreatedTracks.map(x => x.Package()), - Library: req.user?.Library + Bookmarks: req.user!.BookmarkedSongs.map(x => x.Package()), + Created: req.user!.CreatedTracks.map(x => x.Package(true)), + Library: req.user!.Library }) }) @@ -23,17 +34,17 @@ ValidateBody(j.object({ })), async (req, res) => { if (req.user!.Library!.length >= 15) - return res.status(400).json({ errorMessage: "You have too many active songs. Please deactivate some to free up space." }); + return res.status(400).send("You have too many active songs. Please deactivate some to free up space."); 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." }); + return res.status(400).send("This song is already activated."); const SongData = await Song.findOne({ where: { ID: req.body.SongID }, relations: { Author: true } }); if (!SongData) - return res.status(404).json({ errorMessage: "Provided song doesn't exist." }); + return res.status(404).send("Provided song doesn't exist."); - if (SongData.IsDraft && (req.user!.PermissionLevel < UserPermissions.Administrator && SongData.Author.ID !== req.user!.ID)) - return res.status(403).json({ errorMessage: "You cannot subscribe to this track, because it's a draft." }); + if (SongData.IsDraft && (req.user!.PermissionLevel < UserPermissions.TrackVerifier && SongData.Author.ID !== req.user!.ID)) + return res.status(403).send("You cannot activate this track, because it's a draft."); req.user!.Library.push({ SongID: req.body.SongID.toLowerCase(), Overriding: req.body.ToOverride.toLowerCase() }); req.user!.save(); @@ -49,7 +60,7 @@ ValidateBody(j.object({ 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." }); + return res.status(400).send("This song is not activated."); req.user?.Library.splice(idx, 1); req.user?.save(); @@ -64,14 +75,14 @@ ValidateBody(j.object({ })), async (req, res) => { if (req.user?.BookmarkedSongs.findIndex(x => x.ID.toLowerCase() === req.body.SongID.toLowerCase()) !== -1) - return res.status(400).json({ errorMessage: "You're already subscribed to this song." }); + return res.status(400).send("You're already subscribed to this song."); const SongData = await Song.findOne({ where: { ID: req.body.SongID }, relations: { Author: true } }); if (!SongData) - return res.status(404).json({ errorMessage: "Provided song doesn't exist." }); + return res.status(404).send("Provided song doesn't exist."); - if (SongData.IsDraft && (req.user.PermissionLevel < UserPermissions.Administrator && SongData.Author.ID !== req.user.ID)) - return res.status(403).json({ errorMessage: "You cannot subscribe to this track, because it's a draft." }); + if (SongData.IsDraft && (req.user.PermissionLevel < UserPermissions.TrackVerifier && SongData.Author.ID !== req.user.ID)) + return res.status(403).send("You cannot subscribe to this track, because it's a draft."); req.user?.BookmarkedSongs.push(SongData); req.user?.save(); @@ -87,7 +98,7 @@ ValidateBody(j.object({ 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: "You aren't subscribed to this song." }); + return res.status(400).send("You aren't subscribed to this song."); req.user?.BookmarkedSongs.splice(idx, 1); req.user?.save(); @@ -98,12 +109,12 @@ async (req, res) => { App.get("/song/data/:InternalID", RequireAuthentication(), async (req, res) => { - const SongData = await Song.findOne({ where: { ID: req.body.SongID }, relations: { Author: true } }); + const SongData = await Song.findOne({ where: { ID: req.params.InternalID }, relations: { Author: true } }); if (!SongData) - return res.status(404).json({ errorMessage: "Provided song doesn't exist." }); + return res.status(404).send("Provided song doesn't exist."); - if (SongData.IsDraft && (req.user!.PermissionLevel < UserPermissions.Administrator && SongData.Author.ID !== req.user!.ID)) - return res.status(403).json({ errorMessage: "You cannot subscribe to this track, because it's a draft." }); + if (SongData.IsDraft && (req.user!.PermissionLevel < UserPermissions.TrackVerifier && SongData.Author.ID !== req.user!.ID)) + return res.status(403).send("You cannot use this track, because it's a draft."); res.json(SongData.Package()); }) diff --git a/Server/Source/Routes/Moderation.ts b/Server/Source/Routes/Moderation.ts new file mode 100644 index 0000000..207d05e --- /dev/null +++ b/Server/Source/Routes/Moderation.ts @@ -0,0 +1,60 @@ +import j from "joi"; +import { NextFunction, Request, Response, Router } from "express"; +import { RequireAuthentication, ValidateBody } from "../Modules/Middleware"; +import { UserPermissions } from "../Schemas/User"; +import { Song, SongStatus } from "../Schemas/Song"; + +const App = Router(); + +App.use(RequireAuthentication()); + +function PermsLevel(Perms: UserPermissions = UserPermissions.Moderator) { + return (req: Request, res: Response, next: NextFunction) => { + if (!req.user) + return res.status(403).send(); + + if (req.user.PermissionLevel < Perms) + return res.status(403).send("You don't have permission to access this endpoint."); + + next(); + }; +} + +App.get("/submissions", +PermsLevel(UserPermissions.TrackVerifier), +async (_, res) => res.json((await Song.find({ where: { IsDraft: true, Status: SongStatus.AWAITING_REVIEW }, order: { DraftReviewSubmittedAt: "ASC" } })).map(x => x.Package(true)))); + +App.post("/submissions/:Action", +PermsLevel(UserPermissions.TrackVerifier), +ValidateBody(j.object({ + SongID: j.string().uuid().required() +})), +async (req, res) => { + const SongData = await Song.findOne({ where: { ID: req.body.SongID } }); + if (!SongData) + return res.status(404).send("This song does not exist anymore."); + + if (req.params.Action !== "deny" && req.params.Action !== "accept") + return res.status(400).send("Invalid action requested."); + + if (SongData.Status !== SongStatus.AWAITING_REVIEW) + return res.status(400).send("This song is no longer awaiting a review."); + + switch (req.params.Action) { + case "accept": + SongData.Status = SongStatus.ACCEPTED; + break; + + case "deny": + SongData.Status = SongStatus.DENIED; + break; + } + + await SongData.save(); + res.send("Successfully changed song status."); +}); + +export default { + App, + DefaultAPI: "/api/moderation" +} \ No newline at end of file diff --git a/Server/Source/Routes/Pages.ts b/Server/Source/Routes/Pages.ts index 607a5e3..140339c 100644 --- a/Server/Source/Routes/Pages.ts +++ b/Server/Source/Routes/Pages.ts @@ -40,7 +40,7 @@ App.get("/content/api/pages/fortnite-game/:Section", RequireAuthentication(), as const CachedSection = Object.values(FullFortnitePages!).find(x => x._title === req.params.Section); if (!CachedSection) - return res.status(404).json({ errorMessage: "funny section not found haha kill me" }); + return res.status(404).send("funny section not found haha kill me"); const ContentFromServer = await axios.get(`https://fortnitecontent-website-prod07.ol.epicgames.com/content/api/pages/fortnite-game/${CachedSection._title}`); if (ContentFromServer.status !== 200) diff --git a/Server/Source/Routes/Ratings.ts b/Server/Source/Routes/Ratings.ts index 1cfbc9e..8fe47ba 100644 --- a/Server/Source/Routes/Ratings.ts +++ b/Server/Source/Routes/Ratings.ts @@ -9,7 +9,7 @@ const App = Router(); App.get("/:InternalID", async (req, res) => { const SongData = await Song.findOne({ where: { ID: req.params.InternalID }, relations: { Ratings: true } }); if (!SongData) - return res.status(404).json({ errorMessage: "The song you're trying to get the rating for has not been found." }); + return res.status(404).send("The song you're trying to get the rating for has not been found."); let Average = 0; if (SongData.Ratings.length > 0) { @@ -31,7 +31,7 @@ ValidateBody(j.object({ async (req, res) => { const SongData = await Song.findOne({ where: { ID: req.params.InternalID } }); if (!SongData) - return res.status(404).json({ errorMessage: "The song you're trying to get the rating for has not been found." }); + return res.status(404).send("The song you're trying to get the rating for has not been found."); const Existing = req.user?.Ratings.find(x => SongData.ID === x.Rated.ID) if (Existing) diff --git a/Server/Source/Schemas/Song.ts b/Server/Source/Schemas/Song.ts index 1ac38e6..2c68b83 100644 --- a/Server/Source/Schemas/Song.ts +++ b/Server/Source/Schemas/Song.ts @@ -6,6 +6,16 @@ import { v4 } from "uuid"; import { User } from "./User"; import { join } from "path"; +export enum SongStatus { + BROKEN = -100, + DEFAULT = 0, + PROCESSING = 100, + PUBLIC = 200, + AWAITING_REVIEW = 300, + ACCEPTED = 400, + DENIED = 500, +} + @Entity() export class Song extends BaseEntity { @PrimaryGeneratedColumn("uuid") @@ -66,11 +76,20 @@ export class Song extends BaseEntity { IsDraft: boolean; @Column({ default: false }) - DraftAwaitingReview: boolean; + HasMidi: boolean; + + @Column({ default: false }) + HasCover: boolean; + + @Column({ default: false }) + HasAudio: boolean; @Column({ nullable: true }) DraftReviewSubmittedAt?: Date; + @Column({ default: SongStatus.DEFAULT }) + Status: SongStatus; + @Column() CreationDate: Date; @@ -96,9 +115,10 @@ export class Song extends BaseEntity { rmSync(this.Directory, { recursive: true, force: true }); // lets hope this does not cause steam launcher for linux 2.0 } - public Package() { + public Package(IncludeStatus: boolean = false) { return { ...this, + Status: IncludeStatus ? this.Status : SongStatus.DEFAULT, Directory: undefined, // we should NOT reveal that Midi: this.Midi ?? `${FULL_SERVER_ROOT}/song/download/${this.ID}/midi.mid`, Cover: this.Cover ?? `${FULL_SERVER_ROOT}/song/download/${this.ID}/cover.png` diff --git a/Server/Source/Schemas/User.ts b/Server/Source/Schemas/User.ts index a581118..88a5389 100644 --- a/Server/Source/Schemas/User.ts +++ b/Server/Source/Schemas/User.ts @@ -5,6 +5,7 @@ import { Rating } from "./Rating"; export enum UserPermissions { // increments of 100 in case we want to add permissions inbetween without fucking up all instances User = 100, VerifiedUser = 200, + TrackVerifier = 250, Moderator = 300, Administrator = 400 } diff --git a/Server/package-lock.json b/Server/package-lock.json index f07b39c..02e7d6f 100644 --- a/Server/package-lock.json +++ b/Server/package-lock.json @@ -15,10 +15,10 @@ "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", - "exif-reader": "^2.0.0", "express": "^4.18.2", "file-type": "^16.5.3", "fluent-ffmpeg": "^2.1.2", + "image-size": "^1.1.1", "joi": "^17.12.0", "jsonwebtoken": "^9.0.2", "typeorm": "^0.3.19", @@ -917,11 +917,6 @@ "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", @@ -1244,6 +1239,20 @@ } ] }, + "node_modules/image-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz", + "integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1719,6 +1728,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", diff --git a/Server/package.json b/Server/package.json index a0847b4..737dac9 100644 --- a/Server/package.json +++ b/Server/package.json @@ -47,10 +47,10 @@ "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.3.1", - "exif-reader": "^2.0.0", "express": "^4.18.2", "file-type": "^16.5.3", "fluent-ffmpeg": "^2.1.2", + "image-size": "^1.1.1", "joi": "^17.12.0", "jsonwebtoken": "^9.0.2", "typeorm": "^0.3.19", diff --git a/src/App.tsx b/src/App.tsx index 452ce3c..aab3721 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,8 @@ import { ToastContainer } from "react-toastify"; import { BaseStyles, ThemeProvider, theme } from "@primer/react"; import { SiteHeader } from "./components/SiteHeader"; import { VerifyAdmin } from "./components/VerifyAdmin"; +import { VerifyRole } from "./components/VerifyRole"; +import { UserPermissions } from "./utils/Extensions"; import { BrowserRouter, Route, Routes } from "react-router-dom"; import { CookiesProvider } from "react-cookie"; import { Home } from "./routes/Home"; @@ -14,7 +16,7 @@ import { Profile } from "./routes/Profile"; import { NotFound } from "./routes/404"; import { AdminHome } from "./routes/AdminHome"; import { AdminTrackList } from "./routes/AdminTrackList"; -import { AdminCreateTrack } from "./routes/AdminCreateTrack"; +import { AdminSubmissions } from "./routes/AdminSubmissions"; import { AdminFeaturedTab } from "./routes/AdminFeaturedTab"; import { SiteContext, SiteState } from "./utils/State"; import merge from "deepmerge"; @@ -46,10 +48,12 @@ function App() { } /> } /> + {/* Staff routes */} + } /> + {/* Admin routes */} } /> } /> - } /> } /> diff --git a/src/components/Song.tsx b/src/components/Song.tsx index eb9a90e..bcbc169 100644 --- a/src/components/Song.tsx +++ b/src/components/Song.tsx @@ -1,7 +1,53 @@ import { Box, Label, Text } from "@primer/react"; import { Divider } from "@primer/react/lib-esm/ActionList/Divider"; +import { SongStatus } from "../utils/Extensions"; +import { LabelColorOptions } from "@primer/react/lib-esm/Label/Label"; export function Song({ data, children }: { data: any, children?: JSX.Element[] | JSX.Element | string }) { + function GetStatusLabel() { + let Variant: LabelColorOptions = "default"; + let LabelStr = ""; + switch (data.Status) { + case SongStatus.AWAITING_REVIEW: + Variant = "accent"; + LabelStr = "Awaiting review"; + break; + case SongStatus.BROKEN: + Variant = "severe"; + LabelStr = "Missing assets"; + break; + case SongStatus.DEFAULT: + // no label unless draft + Variant = "danger"; + if (data.IsDraft) + LabelStr = "Draft - not published" + break; + case SongStatus.PUBLIC: + Variant = "success"; + LabelStr = "Published"; + break; + case SongStatus.PROCESSING: + Variant = "done"; + LabelStr = "Assets processing"; + break; + case SongStatus.ACCEPTED: + Variant = "sponsors"; + LabelStr = "Ready for publishing"; + break; + case SongStatus.DENIED: + Variant = "danger"; + LabelStr = "ACTION NEEDED - Denied"; + break; + //default: + //LabelStr = `Unimplemented: ${data.Status}`; + //break; + } + + return ( + LabelStr !== "" ? : <> + ) + } + return ( @@ -9,11 +55,7 @@ export function Song({ data, children }: { data: any, children?: JSX.Element[] | {data.ArtistName} {data.Name} { - data.IsDraft ? - data.DraftAwaitingReview ? - : - : - <> + GetStatusLabel() } { children ? : <> diff --git a/src/components/VerifyRole.tsx b/src/components/VerifyRole.tsx new file mode 100644 index 0000000..fb86dc8 --- /dev/null +++ b/src/components/VerifyRole.tsx @@ -0,0 +1,17 @@ +import { useContext } from "react"; +import { Navigate } from "react-router-dom"; +import { SiteContext } from "../utils/State"; +import { toast } from "react-toastify"; +import { UserPermissions } from "../utils/Extensions"; + +export function VerifyRole({ children, role }: { children: JSX.Element, role: UserPermissions }) { + const {state} = useContext(SiteContext); + + if (state.UserDetails?.Role < role) + { + toast("Your account does not have the Partypack role required to access this page. Try again later!", { type: "error" }); + return ; + } + + return children; +} \ No newline at end of file diff --git a/src/routes/AdminCreateTrack.tsx b/src/routes/AdminCreateTrack.tsx deleted file mode 100644 index 89d0ba5..0000000 --- a/src/routes/AdminCreateTrack.tsx +++ /dev/null @@ -1,168 +0,0 @@ -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 formControlStyle = { 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 ( - <> - [ADMIN] Create a New Track -
- - Song Name - - - - Artist - If there are multiple artists, separate them with a comma. - - - - Album - - - - Release Year - - - - Length (in seconds) - - - - 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/AdminSubmissions.tsx b/src/routes/AdminSubmissions.tsx index 303f4e4..352707b 100644 --- a/src/routes/AdminSubmissions.tsx +++ b/src/routes/AdminSubmissions.tsx @@ -1,22 +1,81 @@ -import { useEffect } from "react"; -import { toast } from "react-toastify"; -import axios from "axios"; - -export function AdminSubmissions() { - useEffect(() => { - (async () => { - const Data = await axios.get("/api/admin/"); - const Overrides = await axios.get("/api/library/available"); - - if (Data.status !== 200 || Overrides.status !== 200) - return toast("An error has occured while getting the submitted songs!", { type: "error" }); - - - })(); - }, []); - - return ( - <> - - ) +import { useEffect, useState } from "react"; +import { toast } from "react-toastify"; +import { Heading, Box, Button } from "@primer/react"; +import axios from "axios"; +import { Song } from "../components/Song"; + +export function AdminSubmissions() { + const [submissions, setSubmissions] = useState([]); + const [bookmarks, setBookmarks] = useState([]); + + useEffect(() => { + (async () => { + const Data = await axios.get("/api/moderation/submissions"); + const Overrides = await axios.get("/api/library/available"); + + if (Data.status !== 200 || Overrides.status !== 200) + return toast("An error has occured while getting the submitted songs!", { type: "error" }); + + setSubmissions(Data.data); + })(); + }, []); + + return ( + <> + Submissions waiting for review + + { + submissions.map(x => { + return ( + + { + bookmarks.findIndex(y => y.ID === x.ID) !== -1 ? + : + + } + + + + ) + }) + } + + + ) } \ No newline at end of file diff --git a/src/routes/AdminTrackList.tsx b/src/routes/AdminTrackList.tsx index 3f053d8..0778d9d 100644 --- a/src/routes/AdminTrackList.tsx +++ b/src/routes/AdminTrackList.tsx @@ -21,7 +21,7 @@ export function AdminTrackList() { return ( <> - [ADMIN] All tracks + [ADMIN] All tracks { tracks.map(x => { diff --git a/src/routes/Profile.tsx b/src/routes/Profile.tsx index 57af646..e4c9814 100644 --- a/src/routes/Profile.tsx +++ b/src/routes/Profile.tsx @@ -1,14 +1,18 @@ -import { ActionList, ActionMenu, Avatar, Box, Button, Dialog, Heading, Text } from "@primer/react" +import { ActionList, ActionMenu, Avatar, Box, Button, Dialog, FormControl, Heading, Text, TextInput } from "@primer/react" import { Divider } from "@primer/react/lib-esm/ActionList/Divider"; import { PageHeader } from "@primer/react/drafts"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { SiteContext } from "../utils/State"; import { useCookies } from "react-cookie"; import { Song } from "../components/Song"; import axios from "axios"; import { toast } from "react-toastify"; +import { SongStatus } from "../utils/Extensions"; + +const formControlStyle = { paddingTop: 3 }; export function Profile() { + const formRef = useRef(null); const { state, setState } = useContext(SiteContext); const [, , removeCookie] = useCookies(); const [isActivateDialogOpen, setIsActivateDialogOpen] = useState(false); @@ -17,6 +21,8 @@ export function Profile() { const [draftsSongs, setDraftsSongs] = useState([]); const [availableOverrides, setAvailableOverrides] = useState<{ Name: string, Template: string }[]>([]); const [overriding, setOverriding] = useState({}); + const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); + const [updating, setUpdating] = useState({}); useEffect(() => { (async () => { @@ -26,7 +32,17 @@ export function Profile() { if (Data.status !== 200 || Overrides.status !== 200) return toast("An error has occured while getting your library!", { type: "error" }); - const LibSongs = (await Promise.all(Data.data.Library.map((x: { SongID: string; }) => axios.get(`/api/library/song/data/${x.SongID}`)))).map(x => { return { ...x.data, Override: Data.data.Library.find((y: { SongID: string; }) => y.SongID === x.data.ID).Overriding } }); + const LibSongs = (await Promise.all( + Data.data.Library.map( + (x: { SongID: string; }) => + axios.get(`/api/library/song/data/${x.SongID}`)) + )).map( + x => { + return { + ...x.data, + Override: Data.data.Library.find((y: { SongID: string; }) => y.SongID === x.data.ID).Overriding } + }); + setLibrarySongs(LibSongs); setBookmarkedSongs(Data.data.Bookmarks); setDraftsSongs(Data.data.Created); @@ -52,8 +68,46 @@ export function Profile() { + - Active Songs + + setIsUpdateDialogOpen(false)} aria-labelledby="header"> + Update song + + + { + updating.Status === SongStatus.DENIED ? + "Your song has been denied from being published by staff. In order to re-apply for publishing, please update your song. Your song could've been denied for many reasons, like overcharting, bad chart, spam or troll entry. To find out what the actual reason is, please contact an administrator. Keep in mind that rolling back to previous versions is not possible." : + "Updating your song while it is published will unlist it and queue it for review. Keep in mind that rolling back to previous versions is not possible." + } + +
+ + MIDI File (.mid) + + You can use the #tools-and-resources channel to find useful resources on how to create MIDIs. + + + Audio File (.m4a, .mp3, .wav) + + This will play in the background of your song. Make sure it was exported from REAPER. + + + Cover Image (.png) + + Must be a 1:1 ratio. Max: 2048x2048, min: 512x512 + + +
+
+
+ setIsActivateDialogOpen(false)} aria-labelledby="header"> Activate song @@ -84,6 +138,8 @@ export function Profile() { + + Active Songs { librarySongs.length >= 1 ? @@ -104,6 +160,7 @@ export function Profile() { : You have no activated songs. } + My Subscriptions { @@ -125,32 +182,34 @@ export function Profile() { : You have no bookmarked songs. } + My Drafts & Published Songs { draftsSongs.length >= 1 ? draftsSongs.map((x, i) => { return - - + + - + + + toast(Res.data, { type: Res.status === 200 ? "success" : "error" }); + }}>Delete draft ; }) : You have no bookmarked songs. diff --git a/src/routes/TrackSubmission.tsx b/src/routes/TrackSubmission.tsx index 876b1e7..8d7f361 100644 --- a/src/routes/TrackSubmission.tsx +++ b/src/routes/TrackSubmission.tsx @@ -143,7 +143,7 @@ export function TrackSubmission() { const VocalsDifficulty = (formRef.current[14] as HTMLInputElement).valueAsNumber; const BassDifficulty = (formRef.current[15] as HTMLInputElement).valueAsNumber; - const SongData = await axios.post("/api/drafts/create", { + const B = { Name, ArtistName, Album, @@ -157,7 +157,12 @@ export function TrackSubmission() { DrumsDifficulty, VocalsDifficulty, BassDifficulty - }); + }; + + if (Object.values(B).includes(NaN) || Object.values(B).includes(null) || Object.values(B).includes(undefined)) + return toast("One or more required fields missing.", { type: "error" }); + + const SongData = await axios.post("/api/drafts/create", B); toast(SongData.data, { type: SongData.status === 200 ? "success" : "error" }); diff --git a/src/utils/Extensions.ts b/src/utils/Extensions.ts index d51b296..bcc2db3 100644 --- a/src/utils/Extensions.ts +++ b/src/utils/Extensions.ts @@ -1,3 +1,21 @@ export function moveElement(fromIndex: number, toIndex: number, array: unknown[]) { array.splice(toIndex, 0, array.splice(fromIndex, 1)[0]); +} + +export enum SongStatus { + BROKEN = -100, + DEFAULT = 0, + PROCESSING = 100, + PUBLIC = 200, + AWAITING_REVIEW = 300, + ACCEPTED = 400, + DENIED = 500 +} + +export enum UserPermissions { + User = 100, + VerifiedUser = 200, + TrackVerifier = 250, + Moderator = 300, + Administrator = 400 } \ No newline at end of file diff --git a/src/utils/State.ts b/src/utils/State.ts index 171ef30..1f5b080 100644 --- a/src/utils/State.ts +++ b/src/utils/State.ts @@ -1,9 +1,10 @@ // massive shoutout to not-nullptr for the site state code from our old project import { createContext } from "react"; +import { UserPermissions } from "./Extensions"; export const SiteContext = createContext({} as IContext); -export interface UserDetailInterface { ID: string, Username: string, GlobalName: string, Avatar: string, IsAdmin: boolean } +export interface UserDetailInterface { ID: string, Username: string, GlobalName: string, Avatar: string, IsAdmin: boolean, Role: UserPermissions } export interface SiteState { UserDetails: UserDetailInterface | null;