Skip to content

Commit

Permalink
feat: added the hiding of replays
Browse files Browse the repository at this point in the history
  • Loading branch information
sarahkittyy committed Jan 9, 2023
1 parent 5a412cc commit 7fdd87a
Show file tree
Hide file tree
Showing 26 changed files with 448 additions and 64 deletions.
244 changes: 244 additions & 0 deletions '
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { Request, Response } from 'express';
import validator from 'validator';

import * as tools from '@util/tools';

import { stringify } from 'flatted';

import { prisma } from '@db/index';
import log from '@/log';
import { UserLevelScore } from '@prisma/client';

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;

export class IReplaySearchOptions {
@IsInt({ message: 'Malformed levelId' })
@Min(0)
levelId!: number;

@IsOptional()
@IsNumber(undefined, { message: 'Malformed cursor' })
cursor?: number;

@IsInt({ message: 'Malformed limit' })
@Min(1)
@Max(20)
limit!: number;

@IsIn(SortableFields)
sortBy!: typeof SortableFields[number];

@IsIn(SortDirections)
order!: typeof SortDirections[number];
}

export interface IReplayResponse {
id: number;
user: string;
levelId: number;
time: number;
version: string;
raw: string;
createdAt: number;
updatedAt: number;
alt: boolean;
levelVersion: number;
}

export const ScoreQueryInclude = {
include: {
user: true,
},
};

export const ScoreQueryHide = (uid?: number) => ({
OR: [
{
hidden: false,
},
...(uid != undefined
? [
{
userId: uid,
},
]
: []),
],
});
var ScoreQueryHide = (uid) => ({
OR: [
{
hidden: false,
},
...(uid != undefined
? [
{
userId: uid,
},
]
: []),
],
});

export async function uploadParsedReplay(
data: tools.IReplayData,
userId: number,
levelId?: number
): Promise<tools.UserLevelScoreRunner | undefined> {
try {
let score: tools.UserLevelScoreRunner | undefined = await prisma.userLevelScore.findFirst({
where: {
userId: userId,
levelId: levelId ?? data.header.levelId,
},
...ScoreQueryInclude,
});
const lvl = await prisma.level.findUnique({ where: { id: levelId ?? data.header.levelId } });
if (lvl == null) throw '(NO_FIND_LVL)';
if (score == undefined || data.header.time < score.time || score.levelVersion != lvl.version) {
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,
levelVersion: lvl.version,
},
...ScoreQueryInclude,
});
}
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<tools.UserLevelScoreRunner | undefined> {
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 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 });
}

// hide your own replay file
static async hide(req: Request, res: Response) {
const token: tools.IAuthToken = res.locals.token;
const id = parseInt(req.params.id ?? 'nan');
const hidden: boolean = req.params.hide === 'hide';
if (isNaN(id)) return res.status(400).send({ error: 'Invalid id parameter.' });
try {
const replay = await prisma.userLevelScore.findUnique({
where: { id },
...ScoreQueryInclude,
});
if (replay == null)
return res.status(400).send({ error: 'A replay with that ID was not found.' });
if (replay.user.id != token.id)
return res.status(403).send({ error: 'You do not have permission to hide this replay.' });
await prisma.userLevelScore.update({
where: { id },
data: { hidden },
});
return res.status(200).send();
} catch (e) {
return res.status(500).send({ error: 'Internal server error (NO_HIDE_RPL)' });
}
}

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 {
const replay = await prisma.userLevelScore.findFirst({
where: {
id,
...ScoreQueryHide(token?.id),
},
...ScoreQueryInclude,
});
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) {
return res.status(500).send({ error: 'Internal server error (NO_FETCH_RPL)' });
}
}

// search for replays
static async search(req: Request, res: Response) {
const token: tools.IAuthToken | undefined = res.locals.token;
const levelId = parseInt(req.params.levelId ?? 'nan');
if (isNaN(levelId)) {
return res.status(400).send({ error: 'Invalid levelId parameter.' });
}
let opts: IReplaySearchOptions = new IReplaySearchOptions();
try {
opts.cursor = req.body.cursor ?? -1;
opts.limit = req.body.limit ?? 20;
opts.levelId = levelId;
opts.order = req.body.order ?? 'asc';
opts.sortBy = req.body.sortBy ?? 'time';
const errors = await validate(opts);
if (errors?.length > 0) {
return res.status(400).send({
error: Object.entries(errors[0].constraints ?? [[0, 'Malformed Request Body']])[0][1],
});
}
} catch (e) {
return res.status(400).send({ error: 'Malformed Request Body' });
}

const scores = await prisma.userLevelScore.findMany({
take: opts.limit,
...(opts.cursor > 1 && {
skip: 1,
cursor: {
id: opts.cursor,
},
}),
where: {
levelId,
},
orderBy: [{ levelVersion: 'desc' }, { [opts.sortBy]: opts.order }],
...ScoreQueryInclude,
});

if (scores.length === 0) {
return res.status(200).send({
scores: [],
cursor: -1,
});
}
const lastScore = scores[scores.length - 1];

return res.status(200).send({
scores: scores.map((score) => tools.toReplayResponse(score)),
cursor: lastScore?.id && scores.length >= opts.limit ? lastScore.id : -1,
});
}
}
Binary file added assets/gui/eye_open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/gui/minus.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions backend/controllers/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,12 +325,13 @@ export default class Auth {
static async Login(req: Request, res: Response) {
let email: string | undefined = undefined;
let name: string | undefined = undefined;
if (validator.isEmail(req.body.username)) {
if (validator.isEmail(req.body.username ?? '')) {
email = validator.normalizeEmail(req.body.username) as string;
} else {
name = req.body.username;
}
if (!name && !email) return res.status(400).send({ error: 'No username / email specified' });
if (name == null && email == null)
return res.status(400).send({ error: 'No username / email specified' });
const password: string | undefined = req.body.password;
if (!password) return res.status(400).send({ error: 'No password specified' });

Expand Down
26 changes: 15 additions & 11 deletions backend/controllers/Level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,8 @@ import {
Max,
Min,
validate,
validateOrReject,
} from 'class-validator';
import { ScoreQueryInclude, uploadReplay } from './Replay';
import { ScoreQueryHide, ScoreQueryInclude } from './Replay';

const SortableFields = [
'id',
Expand All @@ -35,20 +34,23 @@ const SortableFields = [
] as const;
const SortDirections = ['asc', 'desc'] as const;

export const LevelQueryInclude = {
export const LevelQueryInclude = (uid?: number) => ({
include: {
author: true,
votes: true,
scores: {
...ScoreQueryInclude,
where: {
...ScoreQueryHide(uid),
},
},
_count: {
select: {
comments: true,
},
},
},
};
});

export interface ILevelResponse {
id: number;
Expand Down Expand Up @@ -143,7 +145,7 @@ export default class Level {
update: {
vote: vote === 'like' ? 1 : -1,
},
include: { level: { ...LevelQueryInclude } },
include: { level: { ...LevelQueryInclude(token.id) } },
});
return res.status(200).send({
level: tools.toLevelResponse(voteModel.level, token.id),
Expand Down Expand Up @@ -238,7 +240,7 @@ export default class Level {
orderBy: {
[opts.sortBy]: opts.order,
},
...LevelQueryInclude,
...LevelQueryInclude(token?.id),
});

if (levels.length === 0) {
Expand All @@ -265,6 +267,7 @@ export default class Level {
* @param {int} req.params.id
*/
static async getById(req: Request, res: Response) {
const token: tools.IAuthToken | undefined = res.locals.token;
const id: string | undefined = req.params.id;
if (!id) {
return res.status(400).send({ error: 'No id specified' });
Expand All @@ -284,7 +287,7 @@ export default class Level {
increment: 1,
},
},
...LevelQueryInclude,
...LevelQueryInclude(token.id),
});
if (!level) {
return res.status(404).send({ error: `Level id "${id}" not found` });
Expand All @@ -301,6 +304,7 @@ export default class Level {

static async getQuickplay(req: Request, res: Response) {
try {
const token: tools.IAuthToken | undefined = res.locals.token;
const levelIds = await prisma.level.findMany({
select: {
id: true,
Expand All @@ -309,7 +313,7 @@ export default class Level {
const { id } = levelIds[Math.floor(Math.random() * levelIds.length)];
const level = await prisma.level.findUnique({
where: { id },
...LevelQueryInclude,
...LevelQueryInclude(token?.id),
});
if (!level) {
return res.status(500).send({ error: `Quickplay fetch failed.` });
Expand Down Expand Up @@ -433,7 +437,7 @@ export default class Level {
data: {
verification: { connect: { id: updatedLevelPreVerify.scores[0].id } },
},
...LevelQueryInclude,
...LevelQueryInclude(token.id),
})
: null;

Expand Down Expand Up @@ -463,7 +467,7 @@ export default class Level {
},
},
},
...LevelQueryInclude,
...LevelQueryInclude(token.id),
});
newLevel =
newLevel != null
Expand All @@ -472,7 +476,7 @@ export default class Level {
data: {
verification: { connect: { id: newLevel.scores[0].id } },
},
...LevelQueryInclude,
...LevelQueryInclude(token.id),
})
: newLevel;

Expand Down
Loading

0 comments on commit 7fdd87a

Please sign in to comment.