suprise commit (i literally changed every file)

This commit is contained in:
mc 2024-01-31 00:27:53 +01:00
parent 2bd4203827
commit 3ca15c2170
27 changed files with 538 additions and 387 deletions

View File

@ -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=

View File

@ -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)") : "")}`));

View File

@ -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.

View File

@ -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)
}
}
}

View File

@ -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({

View File

@ -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"));

View File

@ -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"
}

View File

@ -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");

View File

@ -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.");

View File

@ -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());
})

View File

@ -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"
}

View File

@ -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)

View File

@ -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)

View File

@ -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`

View File

@ -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
}

View File

@ -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",

View File

@ -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",

View File

@ -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() {
<Route path="/profile" element={<Profile />} />
<Route path="*" element={<NotFound />} />
{/* Staff routes */}
<Route path="/mod/submissions" element={<VerifyRole role={UserPermissions.TrackVerifier}><AdminSubmissions /></VerifyRole>} />
{/* Admin routes */}
<Route path="/admin" element={<VerifyAdmin><AdminHome /></VerifyAdmin>} />
<Route path="/admin/tracks" element={<VerifyAdmin><AdminTrackList /></VerifyAdmin>} />
<Route path="/admin/tracks/create" element={<VerifyAdmin><AdminCreateTrack /></VerifyAdmin>} />
<Route path="/admin/featured" element={<VerifyAdmin><AdminFeaturedTab /></VerifyAdmin>} />
</Routes>
</div>

View File

@ -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 !== "" ? <Label variant={Variant}>{LabelStr}</Label> : <></>
)
}
return (
<Box sx={{ overflow: "hidden", minWidth: 50, maxWidth: 200, padding: 2, borderRadius: 10, border: "solid", borderColor: "border.default" }}>
<img src={data.Cover} style={{ width: "100%", borderRadius: 10 }} />
@ -9,11 +55,7 @@ export function Song({ data, children }: { data: any, children?: JSX.Element[] |
<Text sx={{ display: "block", textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" }}>{data.ArtistName}</Text>
<Text sx={{ display: "block", textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" }}><b>{data.Name}</b></Text>
{
data.IsDraft ?
data.DraftAwaitingReview ?
<Label variant="attention">Draft - awaiting review</Label> :
<Label variant="danger">Draft - not published</Label> :
<></>
GetStatusLabel()
}
{
children ? <Divider /> : <></>

View File

@ -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 <Navigate to="/" replace />;
}
return children;
}

View File

@ -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<HTMLFormElement>(null);
const [Key, setKey] = useState<string>("Select a key...");
const [Scale, setScale] = useState<string>("Select a scale...");
const [GuitarStarterType, setGuitarStarterType] = useState<string>("Select the starter type...");
return (
<>
<Heading>[ADMIN] Create a New Track</Heading>
<form method="GET" action="" ref={formRef}>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Song Name</FormControl.Label>
<TextInput />
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Artist</FormControl.Label>
<FormControl.Caption>If there are multiple artists, separate them with a comma.</FormControl.Caption>
<TextInput />
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Album</FormControl.Label>
<TextInput />
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Release Year</FormControl.Label>
<TextInput type="number" />
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Length (in seconds)</FormControl.Label>
<TextInput type="number" />
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Key</FormControl.Label>
<ActionMenu>
<ActionMenu.Button>{Key}</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
{
["A", "Ab", "B", "Bb", "C", "Cb", "D", "Db", "E", "Eb", "F", "Fb", "G", "Gb"].map(x => {
return (
<ActionList.Item onSelect={() => setKey(x)}>
{x}
</ActionList.Item>
)
})
}
</ActionMenu.Overlay>
</ActionMenu>
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Scale</FormControl.Label>
<ActionMenu>
<ActionMenu.Button>{Scale}</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList.Item onSelect={() => setScale("Minor")}>Minor</ActionList.Item>
<ActionList.Item onSelect={() => setScale("Major")}>Major</ActionList.Item>
</ActionMenu.Overlay>
</ActionMenu>
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Lead Type</FormControl.Label>
<FormControl.Caption>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.</FormControl.Caption>
<ActionMenu>
<ActionMenu.Button>{GuitarStarterType}</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList.Item onSelect={() => setGuitarStarterType("Guitar")}>Guitar</ActionList.Item>
<ActionList.Item onSelect={() => setGuitarStarterType("Keytar")}>Keytar</ActionList.Item>
</ActionMenu.Overlay>
</ActionMenu>
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Tempo</FormControl.Label>
<TextInput type="number" />
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>MIDI File</FormControl.Label>
<TextInput type="file" />
<FormControl.Caption>You can use the #tools-and-resources channel to find useful resources on how to create MIDIs.</FormControl.Caption>
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Cover Image</FormControl.Label>
<TextInput type="file" />
<FormControl.Caption>Must be a 1:1 ratio. Max: 2048x2048, min: 512x512</FormControl.Caption>
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Lead Difficulty</FormControl.Label>
<TextInput type="number" />
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Drums Difficulty</FormControl.Label>
<TextInput type="number" />
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Vocals Difficulty</FormControl.Label>
<TextInput type="number" />
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Bass Difficulty</FormControl.Label>
<TextInput type="number" />
</FormControl>
<Button sx={{ marginTop: 2 }} type="submit" onClick={async e => {
e.preventDefault();
console.log(formRef);
if (formRef.current == null)
return;
const Name = (formRef.current[0] as HTMLInputElement).value;
const ArtistName = (formRef.current[1] as HTMLInputElement).value;
const Album = (formRef.current[2] as HTMLInputElement).value;
const Year = (formRef.current[3] as HTMLInputElement).value;
const Length = (formRef.current[4] as HTMLInputElement).valueAsNumber;
//const Key = (formRef.current[5] as HTMLInputElement).value;
//const Scale = (formRef.current[6] as HTMLInputElement).value;
//const GuitarStarterType = (formRef.current[7] as HTMLInputElement).value;
const Tempo = (formRef.current[8] as HTMLInputElement).valueAsNumber;
const Midi = (formRef.current[9] as HTMLInputElement).files![0];
const Cover = (formRef.current[10] as HTMLInputElement).files![0];
const GuitarDifficulty = (formRef.current[11] as HTMLInputElement).valueAsNumber;
const DrumsDifficulty = (formRef.current[12] as HTMLInputElement).valueAsNumber;
const VocalsDifficulty = (formRef.current[13] as HTMLInputElement).valueAsNumber;
const BassDifficulty = (formRef.current[14] as HTMLInputElement).valueAsNumber;
const SongData = await axios.post("/api/admin/create/song", {
Name,
ArtistName,
Album,
Year,
Length,
Key,
Scale,
GuitarStarterType,
Tempo,
GuitarDifficulty,
DrumsDifficulty,
VocalsDifficulty,
BassDifficulty
});
toast(SongData.data, { type: SongData.status === 200 ? "success" : "error" });
if (SongData.status !== 200)
return;
const MidiRes = await axios.post("/api/admin/upload/midi", {
Data: Buffer.from(
await Midi.arrayBuffer()
).toString("hex"),
TargetSong: SongData.data.ID
});
toast(MidiRes.data, { type: MidiRes.status === 200 ? "success" : "error" });
const CoverRes = await axios.post("/api/admin/upload/cover", { Data: Buffer.from(await Cover.arrayBuffer()).toString("hex"), TargetSong: SongData.data.ID });
toast(CoverRes.data, { type: CoverRes.status === 200 ? "success" : "error" });
}}>Create</Button>
</form>
</>
)
}

View File

@ -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<unknown[]>([]);
const [bookmarks, setBookmarks] = useState<unknown[]>([]);
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 (
<>
<Heading>Submissions waiting for review</Heading>
<Box className="songCategory">
{
submissions.map(x => {
return (
<Song data={x}>
{
bookmarks.findIndex(y => y.ID === x.ID) !== -1 ?
<Button sx={{ width: "100%", marginBottom: 1 }} variant="danger" onClick={async () => {
const Res = await axios.post("/api/library/me/unbookmark", { SongID: x.ID });
if (Res.status === 200)
{
bookmarks.splice(bookmarks.findIndex(y => y.ID === x.ID), 1);
setBookmarks([...bookmarks]);
}
else
toast(Res.data, { type: "error" })
}}>Unsubscribe</Button> :
<Button sx={{ width: "100%", marginBottom: 1 }} variant="primary" onClick={async () => {
const Res = await axios.post("/api/library/me/bookmark", { SongID: x.ID });
if (Res.status === 200)
{
bookmarks.push(x);
setBookmarks([...bookmarks]);
}
else
toast(Res.data, { type: "error" })
}}>Subscribe</Button>
}
<Button sx={{ width: "100%", marginBottom: 1 }} variant="danger" onClick={async () => {
const Res = await axios.post("/api/moderation/submissions/accept", { SongID: x.ID });
if (Res.status === 200)
{
submissions.splice(submissions.findIndex(y => y.ID === x.ID), 1);
setSubmissions([...submissions]);
}
else
toast(Res.data, { type: "error" })
}}>Approve</Button>
<Button sx={{ width: "100%" }} variant="danger" onClick={async () => { // TODO: reasons
const Res = await axios.post("/api/moderation/submissions/deny", { SongID: x.ID });
if (Res.status === 200)
{
submissions.splice(submissions.findIndex(y => y.ID === x.ID), 1);
setSubmissions([...submissions]);
}
else
toast(Res.data, { type: "error" })
}}>Deny</Button>
</Song>
)
})
}
</Box>
</>
)
}

View File

@ -21,7 +21,7 @@ export function AdminTrackList() {
return (
<>
<Heading>[ADMIN] All tracks <Button sx={{ marginBottom: 2 }} onClick={() => navigate("/admin/tracks/create")}>Create</Button></Heading>
<Heading>[ADMIN] All tracks <Button sx={{ marginBottom: 2 }} onClick={() => navigate("/submissions")}>Create</Button></Heading>
<Box className="songCategory">
{
tracks.map(x => {

View File

@ -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<HTMLFormElement>(null);
const { state, setState } = useContext(SiteContext);
const [, , removeCookie] = useCookies();
const [isActivateDialogOpen, setIsActivateDialogOpen] = useState<boolean>(false);
@ -17,6 +21,8 @@ export function Profile() {
const [draftsSongs, setDraftsSongs] = useState<unknown[]>([]);
const [availableOverrides, setAvailableOverrides] = useState<{ Name: string, Template: string }[]>([]);
const [overriding, setOverriding] = useState<unknown>({});
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState<boolean>(false);
const [updating, setUpdating] = useState<unknown>({});
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() {
</PageHeader.Actions>
</PageHeader.TitleArea>
</PageHeader>
<Divider />
<Heading sx={{ marginBottom: 2 }}>Active Songs</Heading>
<Dialog isOpen={isUpdateDialogOpen} onDismiss={() => setIsUpdateDialogOpen(false)} aria-labelledby="header">
<Dialog.Header id="header">Update song</Dialog.Header>
<Box p={3}>
<Text>
{
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."
}
</Text>
<form method="GET" action="" ref={formRef}>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>MIDI File (.mid)</FormControl.Label>
<TextInput sx={{ width: "100%" }} type="file" />
<FormControl.Caption>You can use the #tools-and-resources channel to find useful resources on how to create MIDIs.</FormControl.Caption>
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Audio File (.m4a, .mp3, .wav)</FormControl.Label>
<TextInput sx={{ width: "100%" }} type="file" />
<FormControl.Caption>This will play in the background of your song. Make sure it was exported from REAPER.</FormControl.Caption>
</FormControl>
<FormControl required={true} sx={formControlStyle}>
<FormControl.Label>Cover Image (.png)</FormControl.Label>
<TextInput sx={{ width: "100%" }} type="file" />
<FormControl.Caption>Must be a 1:1 ratio. Max: 2048x2048, min: 512x512</FormControl.Caption>
</FormControl>
<Button type="submit" sx={{ marginTop: 3, width: "100%" }} onClick={e => {
e.preventDefault();
const Midi = (formRef.current[0] as HTMLInputElement).files![0];
const Music = (formRef.current[1] as HTMLInputElement).files![0];
const Cover = (formRef.current[2] as HTMLInputElement).files![0];
}}>{ updating.Status === SongStatus.PUBLIC ? "Unlist and Update" : "Update" }</Button>
</form>
</Box>
</Dialog>
<Dialog isOpen={isActivateDialogOpen} onDismiss={() => setIsActivateDialogOpen(false)} aria-labelledby="header">
<Dialog.Header id="header">Activate song</Dialog.Header>
<Box p={3}>
@ -84,6 +138,8 @@ export function Profile() {
</ActionMenu>
</Box>
</Dialog>
<Heading sx={{ marginBottom: 2 }}>Active Songs</Heading>
<Box className="songCategory">
{
librarySongs.length >= 1 ?
@ -104,6 +160,7 @@ export function Profile() {
: <Text>You have no activated songs.</Text>
}
</Box>
<Heading sx={{ marginTop: 2, marginBottom: 2 }}>My Subscriptions</Heading>
<Box className="songCategory">
{
@ -125,32 +182,34 @@ export function Profile() {
: <Text>You have no bookmarked songs.</Text>
}
</Box>
<Heading sx={{ marginTop: 2, marginBottom: 2 }}>My Drafts & Published Songs</Heading>
<Box className="songCategory">
{
draftsSongs.length >= 1 ?
draftsSongs.map((x, i) => {
return <Song data={x}>
<Button sx={{ width: "100%", marginBottom: 1 }} variant="primary" onClick={() => { setIsActivateDialogOpen(true); setOverriding(x) }} disabled={librarySongs.findIndex(y => y.ID === x.ID) !== -1}>Add to Active</Button>
<Button sx={{ width: "100%", marginBottom: 1 }} onClick={async () => {
<Button sx={{ width: "100%", marginBottom: 1 }} variant="primary" onClick={() => { setIsActivateDialogOpen(true); setOverriding(x) }} disabled={x.Status === SongStatus.BROKEN || x.Status === SongStatus.DENIED || x.Status === SongStatus.PROCESSING || librarySongs.findIndex(y => y.ID === x.ID) !== -1}>Add to Active</Button>
<Button sx={{ width: "100%", marginBottom: 1 }} variant="primary" onClick={() => { setIsUpdateDialogOpen(true); setUpdating(x) }} disabled={x.Status !== SongStatus.BROKEN && x.Status !== SongStatus.DEFAULT && x.Status !== SongStatus.DENIED && x.Status !== SongStatus.PUBLIC}>Update</Button>
<Button disabled={x.Status !== SongStatus.DEFAULT && x.Status !== SongStatus.ACCEPTED} sx={{ width: "100%", marginBottom: 1 }} onClick={async () => {
const Res = await axios.post("/api/drafts/submit", { TargetSong: x.ID });
if (Res.status === 200) {
x.DraftAwaitingReview = true;
x.Status = x.Status === SongStatus.AWAITING_REVIEW ? SongStatus.PUBLIC : SongStatus.AWAITING_REVIEW;
draftsSongs[i] = x;
setDraftsSongs([...draftsSongs]);
}
else
toast(Res.data, { type: "error" });
}}>Publish</Button>
<Button sx={{ width: "100%" }} variant="danger" onClick={async () => {
toast(Res.data, { type: Res.status === 200 ? "success" : "error" });
}}>{x.Status === SongStatus.DEFAULT ? "Submit for Review" : "Publish"}</Button>
<Button disabled={!state.UserDetails.IsAdmin && x.Status !== SongStatus.DEFAULT && x.Status !== SongStatus.DENIED && x.Status !== SongStatus.BROKEN} sx={{ width: "100%" }} variant="danger" onClick={async () => {
const Res = await axios.post("/api/drafts/delete", { TargetSong: x.ID });
if (Res.status === 200) {
draftsSongs.splice(draftsSongs.findIndex(y => y.ID === x.ID), 1);
setDraftsSongs([...draftsSongs]);
}
else
toast(Res.data, { type: "error" });
}}>Unsubscribe</Button>
toast(Res.data, { type: Res.status === 200 ? "success" : "error" });
}}>Delete draft</Button>
</Song>;
})
: <Text>You have no bookmarked songs.</Text>

View File

@ -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" });

View File

@ -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
}

View File

@ -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<IContext>({} 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;