diff --git a/backend/controllers/Level.ts b/backend/controllers/Level.ts index e5fd96e..533f4d9 100644 --- a/backend/controllers/Level.ts +++ b/backend/controllers/Level.ts @@ -21,6 +21,7 @@ import { validate, validateOrReject, } from 'class-validator'; +import { uploadReplay } from './Replay'; const SortableFields = [ 'id', @@ -107,6 +108,7 @@ export interface ILevelResponse { records: number; comments: number; myVote?: 1 | 0 | -1; + verificationId?: number; } /* what is returned from the search endpoint */ @@ -352,9 +354,9 @@ export default class Level { return res.status(400).send({ error: 'Description too long! Max 256 characters' }); } - const user = await prisma.user.findFirst({ + const user = await prisma.user.findUnique({ where: { - name: token.username, + id: token.id, }, include: { levels: true }, }); @@ -375,6 +377,16 @@ export default class Level { } } + const verificationb64: string | undefined = req.body.verification; + if (verificationb64 == null) { + return res.status(400).send({ error: 'You must verify a level before posting it!' }); + } + const verification: Buffer = Buffer.from(verificationb64, 'base64'); + const replayData: tools.IReplayData | undefined = tools.decodeRawReplay(verification); + if (replayData == null) { + return res.status(400).send({ error: 'Invalid level verification.' }); + } + const existingLevel = await prisma.level.findFirst({ where: { title } }); if (existingLevel) { if (user.id !== existingLevel.authorId) { @@ -387,16 +399,41 @@ export default class Level { .status(409) .send({ error: `A level with that title already exists (ID ${existingLevel.id})` }); } - const updatedLevel = await prisma.level.update({ + const updatedLevelPreVerify = await prisma.level.update({ where: { id: existingLevel.id }, data: { title, description, code, updatedAt: new Date(), + scores: { + create: { + user: { connect: { id: token.id } }, + replay: replayData.raw, + time: replayData.header.time, + version: replayData.header.version, + alt: replayData.header.alt, + }, + }, + }, + include: { + scores: { + orderBy: { createdAt: 'desc' }, + take: 1, + }, }, - ...LevelQueryInclude, }); + const updatedLevel = + updatedLevelPreVerify != null + ? await prisma.level.update({ + where: { id: updatedLevelPreVerify.id }, + data: { + verification: { connect: { id: updatedLevelPreVerify.scores[0].id } }, + }, + ...LevelQueryInclude, + }) + : null; + if (!updatedLevel) return res.status(500).send({ error: 'Internal server error (NO_OVERWRITE_LEVEL)' }); log.info( @@ -407,15 +444,34 @@ export default class Level { }); } - const newLevel = await prisma.level.create({ + let newLevel = await prisma.level.create({ data: { code, author: { connect: { id: user.id } }, title, description, + scores: { + create: { + user: { connect: { id: user.id } }, + replay: replayData.raw, + time: replayData.header.time, + version: replayData.header.version, + alt: replayData.header.alt, + }, + }, }, ...LevelQueryInclude, }); + newLevel = + newLevel != null + ? await prisma.level.update({ + where: { id: newLevel.id }, + data: { + verification: { connect: { id: newLevel.scores[0].id } }, + }, + ...LevelQueryInclude, + }) + : newLevel; if (!newLevel) { return res.status(500).send({ error: 'Internal server error (NO_POST_LEVEL)' }); diff --git a/backend/controllers/Replay.ts b/backend/controllers/Replay.ts index 950550d..f648665 100644 --- a/backend/controllers/Replay.ts +++ b/backend/controllers/Replay.ts @@ -9,18 +9,7 @@ import { prisma } from '@db/index'; import log from '@/log'; import { UserLevelScore } from '@prisma/client'; -import { - IsBoolean, - IsIn, - IsInt, - IsNumber, - IsOptional, - IsString, - Max, - Min, - validate, - validateOrReject, -} from 'class-validator'; +import { IsIn, IsInt, IsNumber, IsOptional, Max, Min, validate } from 'class-validator'; const SortableFields = ['time', 'author', 'createdAt', 'updatedAt'] as const; const SortDirections = ['asc', 'desc'] as const; @@ -47,6 +36,7 @@ export class IReplaySearchOptions { } export interface IReplayResponse { + id: number; user: string; levelId: number; time: number; @@ -57,41 +47,84 @@ export interface IReplayResponse { alt: boolean; } +export async function uploadParsedReplay( + data: tools.IReplayData, + userId: number, + levelId?: number +): Promise { + try { + let score: tools.UserLevelScoreRunner | undefined = await prisma.userLevelScore.findFirst({ + where: { + userId: userId, + levelId: levelId ?? data.header.levelId, + }, + include: { + user: true, + }, + }); + if (score == undefined || data.header.time <= score.time) { + score = await prisma.userLevelScore.create({ + data: { + user: { connect: { id: userId } }, + level: { connect: { id: levelId ?? data.header.levelId } }, + replay: data.raw, + time: data.header.time, + version: data.header.version, + alt: data.header.alt, + }, + include: { + user: true, + }, + }); + } + return score; + } catch (e) { + log.warn(`Failure to upload level score: ${stringify(e)}`); + return undefined; + } +} + +export async function uploadReplay( + b64replay: string, + userId: number, + levelId?: number +): Promise { + const buf = Buffer.from(b64replay, 'base64'); + const data: tools.IReplayData | undefined = tools.decodeRawReplay(buf); + if (!data) return undefined; + return await uploadParsedReplay(data, userId, levelId); +} + // replay controller export default class Replay { // upload a new replay file static async upload(req: Request, res: Response) { const token: tools.IAuthToken = res.locals.token; const b64replay = req.body.replay; - const buf = Buffer.from(b64replay, 'base64'); - const data: tools.IReplayData | undefined = tools.decodeRawReplay(buf); - if (!data) return res.status(400).send({ error: 'Invalid base64 replay file.', data }); + const replay: tools.UserLevelScoreRunner | undefined = await uploadReplay(b64replay, token.id); + if (replay == null) { + return res.status(400).send({ error: 'Invalid replay base64 data.' }); + } + return res.status(200).send({ newBest: replay.time }); + } + + static async get(req: Request, res: Response) { + const token: tools.IAuthToken | undefined = res.locals.token; + const id = parseInt(req.params.id ?? 'nan'); + if (isNaN(id)) return res.status(400).send({ error: 'Invalid id parameter.' }); try { - let score: UserLevelScore | undefined = await prisma.userLevelScore.findFirst({ - where: { - userId: token.id, - levelId: data.header.levelId, + const replay = await prisma.userLevelScore.findUnique({ + where: { id }, + include: { + user: true, }, }); - if (score == undefined || data.header.time <= score.time) { - score = await prisma.userLevelScore.create({ - data: { - user: { connect: { id: token.id } }, - level: { connect: { id: data.header.levelId } }, - replay: buf, - time: data.header.time, - version: data.header.version, - alt: data.header.alt, - }, - }); - } - return res.status(200).send({ newBest: score.time }); + if (replay == null) + return res.status(400).send({ error: 'A replay with that ID was not found.' }); + return res.status(200).send({ replay: tools.toReplayResponse(replay) }); } catch (e) { - log.warn(`Failure to upload level score: ${stringify(e)}`); - return res - .status(400) - .send({ error: `Score failed to send ${e?.code ?? '(UNKNOWN_ERROR)'}` }); + return res.status(500).send({ error: 'Internal server error (NO_FETCH_RPL)' }); } } diff --git a/backend/index.ts b/backend/index.ts index ba5c151..f88a3d9 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -36,6 +36,7 @@ app.post('/level/:id', checkAuth(), Level.getById); app.post('/replay/upload', requireAuth(0), Replay.upload); app.post('/replay/search/:levelId(\\d+)', checkAuth(), Replay.search); +app.post('/replay/:id(\\d+)', checkAuth(), Replay.get); app.post('/comments/level/:levelId(\\d+)', Comment.get); app.post('/comments/new/:levelId(\\d+)', requireAuth(0), Comment.post); diff --git a/backend/util/tools.ts b/backend/util/tools.ts index 0453501..13690e7 100644 --- a/backend/util/tools.ts +++ b/backend/util/tools.ts @@ -219,6 +219,7 @@ export function toCommentResponse(comment: UserLevelCommentPoster): ICommentResp export function toReplayResponse(replay: UserLevelScoreRunner): IReplayResponse { return { + id: replay.id, user: replay.user.name, levelId: replay.levelId, raw: replay.replay.toString('base64'), @@ -276,6 +277,9 @@ export function toLevelResponse(lvl: LevelMetadataIncluded, userId?: number): IL records: lvl.scores.length, comments: lvl._count.comments, myVote: userId != null ? userVote(userId, lvl.votes) : undefined, + ...(lvl.verificationId != null && { + verificationId: lvl.verificationId, + }), }; } diff --git a/game/api.cpp b/game/api.cpp index d8f4f7f..eded6cb 100644 --- a/game/api.cpp +++ b/game/api.cpp @@ -439,8 +439,8 @@ std::future api::quickplay_level() { }); } -std::future api::upload_level(::level l, const char *title, const char *description, bool override) { - return std::async([this, l, title, description, override]() -> api::level_response { +std::future api::upload_level(::level l, ::replay verify, const char *title, const char *description, bool override) { + return std::async([this, l, title, description, override, verify]() -> api::level_response { if (!auth::get().authed()) { return { .success = false, @@ -450,9 +450,10 @@ std::future api::upload_level(::level l, const char *title, } try { nlohmann::json body; - body["code"] = l.map().save(); - body["title"] = title; - body["description"] = description; + body["code"] = l.map().save(); + body["title"] = title; + body["description"] = description; + body["verification"] = verify.serialize_b64(); auth::get().add_jwt_to_body(body); std::string path = override ? "/level/upload/confirm" : "/level/upload"; if (auto res = m_cli.Post(path, body.dump(), "application/json")) { @@ -608,7 +609,8 @@ std::future api::is_up_to_date() { } bool api::replay::operator==(const api::replay &other) const { - return other.createdAt == createdAt && // + return other.id == id && + other.createdAt == createdAt && // other.updatedAt == updatedAt && other.levelId == levelId && other.time == time && @@ -709,6 +711,9 @@ void from_json(const nlohmann::json &j, api::level &l) { if (j.contains("records")) { l.records = j["records"].get(); } + if (j.contains("verificationId")) { + l.verificationId = j["verificationId"].get(); + } if (j.contains("myRecord")) { l.myRecord = j["myRecord"].get(); @@ -733,5 +738,7 @@ void to_json(nlohmann::json &j, const api::level &l) { j["record"] = *l.record; if (l.myRecord) j["myRecord"] = *l.myRecord; + if (l.verificationId) + j["verificationId"] = *l.verificationId; j["records"] = l.records; } diff --git a/game/api.hpp b/game/api.hpp index 6abe4d9..29660bf 100644 --- a/game/api.hpp +++ b/game/api.hpp @@ -38,9 +38,14 @@ class api { std::optional myRecord; int records; std::optional myVote; + std::optional verificationId; + inline bool verified() const { + return verificationId.has_value(); + } }; struct replay { + int id; std::string user; int levelId; float time; @@ -49,7 +54,7 @@ class api { std::time_t createdAt; std::time_t updatedAt; bool alt; - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(api::replay, user, levelId, time, version, raw, createdAt, updatedAt, alt); + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(api::replay, id, user, levelId, time, version, raw, createdAt, updatedAt, alt); bool operator==(const replay& other) const; bool operator!=(const replay& other) const; }; @@ -197,7 +202,7 @@ class api { std::optional stats; }; - std::future upload_level(::level l, const char* title, const char* description, bool override = false); + std::future upload_level(::level l, ::replay verify, const char* title, const char* description, bool override = false); std::future download_level(int id); std::future quickplay_level(); std::future vote_level(api::level lvl, vote v); diff --git a/game/gui/leaderboard_modal.cpp b/game/gui/leaderboard_modal.cpp index cc000bf..cc447f9 100644 --- a/game/gui/leaderboard_modal.cpp +++ b/game/gui/leaderboard_modal.cpp @@ -172,6 +172,13 @@ void leaderboard_modal::imdraw(fsm* sm) { } ImGui::SameLine(); } + if (m_lvl.verificationId.has_value() && *m_lvl.verificationId == score.id) { + ImGui::Image(resource::get().imtex("assets/gui/home.png"), sf::Vector2f(16, 16)); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Original verification"); + } + ImGui::SameLine(); + } ImGui::Text("%s", score.user.c_str()); ImGui::TableNextColumn(); ImGui::Text("%.2f", score.time); diff --git a/game/states/edit.cpp b/game/states/edit.cpp index a6cf1c5..e5a81ba 100644 --- a/game/states/edit.cpp +++ b/game/states/edit.cpp @@ -181,6 +181,10 @@ void edit::update(fsm* sm, sf::Time dt) { // metadata check if (m_level().has_metadata() && !m_is_current_level_ours()) m_level().clear_metadata(); + // verification check + if (m_verification) { + m_verification.reset(); + } // set the tile switch (m_cursor_type) { @@ -462,9 +466,13 @@ void edit::imdraw(fsm* sm) { ImGui::Begin("Level Info"); m_gui_level_info(sm); ImGui::End(); - - // victory - if (m_test_play_world && m_test_play_world->won() && !m_test_play_world->has_playback()) { + } + // victory + if (m_test_play_world && m_test_play_world->won() && !m_test_play_world->has_playback()) { + if (m_is_current_level_ours() && !m_verification) { + m_verification.reset(new ::replay(m_test_play_world->get_replay())); + } + if (m_level().has_metadata()) { ImGui::SetNextWindowPos(ImVec2(wsz.x / 2.f - 150, wsz.y / 2.f + 75), ImGuiCond_Appearing); ImGui::SetNextWindowSize(ImVec2(300, 250), ImGuiCond_Appearing); ImGui::Begin("Replay"); @@ -659,9 +667,9 @@ void edit::m_gui_controls(fsm* sm) { if (ImGui::ImageButtonWithText(resource::get().imtex("assets/gui/erase.png"), "Clear")) { ImGui::OpenPopup("Clear###Confirm"); } - ImGui::BeginDisabled(!m_is_current_level_ours() || !m_level().valid() || !auth::get().authed()); + ImGui::BeginDisabled(!m_is_current_level_ours() || !m_level().valid() || !auth::get().authed() || !m_verification); if (ImGui::ImageButtonWithText(resource::get().imtex("assets/gui/upload.png"), "Upload")) { - ImGui::OpenPopup("Upload###Upload"); + if (m_verification) ImGui::OpenPopup("Upload###Upload"); } ImGui::EndDisabled(); if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) { @@ -671,6 +679,8 @@ void edit::m_gui_controls(fsm* sm) { ImGui::SetTooltip("Cannot upload a level without a start & end point."); } else if (!m_is_current_level_ours()) { ImGui::SetTooltip("Cannot re-upload someone else's level. Clear first to make your own level for posting."); + } else if (!m_verification) { + ImGui::SetTooltip("Cannot upload a level without verifying it first."); } } ImGui::SameLine(); @@ -706,6 +716,7 @@ void edit::m_gui_controls(fsm* sm) { ///////////////// UPLOAD LOGIC //////////////////////// bool upload_modal_open = m_level().valid(); if (ImGui::BeginPopupModal("Upload###Upload", &upload_modal_open, modal_flags)) { + if (!m_verification) return ImGui::EndPopup(); if (m_upload_handle.ready() && !m_upload_handle.get().success && m_upload_handle.get().error) { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(sf::Color::Red)); ImGui::TextWrapped("%s", m_upload_handle.get().error->c_str()); @@ -716,13 +727,16 @@ void edit::m_gui_controls(fsm* sm) { } if (ImGui::InputText("Description", m_description_buffer, 256, ImGuiInputTextFlags_EnterReturnsTrue)) { if (!m_upload_handle.fetching()) - m_upload_handle.reset(api::get().upload_level(m_level(), m_title_buffer, m_description_buffer)); + m_upload_handle.reset(api::get().upload_level(m_level(), *m_verification, m_title_buffer, m_description_buffer)); } ImGui::BeginDisabled(m_upload_handle.fetching()); const char* upload_label = m_upload_handle.fetching() ? "Uploading...###UploadForReal" : "Upload###UploadForReal"; if (ImGui::ImageButtonWithText(resource::get().imtex("assets/gui/upload.png"), upload_label)) { - if (!m_upload_handle.fetching()) - m_upload_handle.reset(api::get().upload_level(m_level(), m_title_buffer, m_description_buffer)); + if (!m_upload_handle.fetching()) { + m_verification->set_created_now(); + m_verification->set_user(auth::get().username().c_str()); + m_upload_handle.reset(api::get().upload_level(m_level(), *m_verification, m_title_buffer, m_description_buffer)); + } } ImGui::EndDisabled(); if (m_upload_handle.ready()) { @@ -737,7 +751,7 @@ void edit::m_gui_controls(fsm* sm) { ImGui::TextWrapped("A level named %s already exists, do you want to overwrite it?", m_title_buffer); if (ImGui::ImageButtonWithText(resource::get().imtex("assets/gui/yes.png"), "Yes###OverrideYes")) { m_upload_handle.reset(); - m_upload_handle.reset(api::get().upload_level(m_level(), m_title_buffer, m_description_buffer, true)); + m_upload_handle.reset(api::get().upload_level(m_level(), *m_verification, m_title_buffer, m_description_buffer, true)); ImGui::CloseCurrentPopup(); } ImGui::SameLine(); @@ -752,6 +766,15 @@ void edit::m_gui_controls(fsm* sm) { } ///////////////// UPLOAD LOGIC END //////////////////////// ImGui::Separator(); + // verification + if (!m_level().has_metadata() || m_is_current_level_ours()) { + if (m_verification) { + ImGui::Text("Verified in %.2fs", m_verification->get_time()); + } else { + ImGui::TextWrapped("Level not verified. Beat the level before posting."); + } + ImGui::Separator(); + } if (m_loaded_replay) { ImGui::Text("Replay: %.2f by %s%s", m_loaded_replay->get_time(), m_loaded_replay->get_user(), m_loaded_replay->alt() ? " (bb controls)" : ""); if (ImGui::ImageButtonWithText(resource::get().imtex("assets/gui/erase.png"), "Unload###UL")) { diff --git a/game/states/edit.hpp b/game/states/edit.hpp index 1a3b1bf..3df43b0 100644 --- a/game/states/edit.hpp +++ b/game/states/edit.hpp @@ -108,6 +108,8 @@ class edit : public state { sf::Text m_timer_text; void m_toggle_test_play(); + std::unique_ptr<::replay> m_verification; + std::string m_info_msg; void m_update_transforms(); // update the transforms based on m_level_size diff --git a/prisma/migrations/20230107050448_add_verification_level/migration.sql b/prisma/migrations/20230107050448_add_verification_level/migration.sql new file mode 100644 index 0000000..237bcb5 --- /dev/null +++ b/prisma/migrations/20230107050448_add_verification_level/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[verificationId]` on the table `Level` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Level" ADD COLUMN "verificationId" INTEGER; + +-- CreateIndex +CREATE UNIQUE INDEX "Level_verificationId_key" ON "Level"("verificationId"); + +-- AddForeignKey +ALTER TABLE "Level" ADD CONSTRAINT "Level_verificationId_fkey" FOREIGN KEY ("verificationId") REFERENCES "UserLevelScore"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d4775da..eaafcbb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,20 +28,22 @@ model User { } model Level { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) - code String @db.Text - author User @relation(fields: [authorId], references: id) - authorId Int - title String @db.VarChar(50) @unique - description String? @db.Text - downloads Int @default(0) - likes Int @default(0) - dislikes Int @default(0) - votes UserLevelVote[] - scores UserLevelScore[] - comments UserLevelComment[] + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + code String @db.Text + author User @relation(fields: [authorId], references: id) + authorId Int + title String @db.VarChar(50) @unique + description String? @db.Text + downloads Int @default(0) + likes Int @default(0) + dislikes Int @default(0) + votes UserLevelVote[] + scores UserLevelScore[] @relation("level") + comments UserLevelComment[] + verification UserLevelScore? @relation(fields: [verificationId], references: id, name: "verify") + verificationId Int? @unique } model UserLevelComment { @@ -73,12 +75,13 @@ model UserLevelScore { updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) userId Int - level Level @relation(fields: [levelId], references: [id]) + level Level @relation(fields: [levelId], references: [id], name: "level") levelId Int time Float version String @db.VarChar(10) replay Bytes alt Boolean @default(false) + verifies Level? @relation("verify") @@id([id, userId, levelId]) }