Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Client-Side Pre-Upload Package Validation #1061

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions builder/src/components/DragDropFileInput.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import React, { CSSProperties, useEffect, useRef, useState } from "react";
import React, {
CSSProperties,
MutableRefObject,
useEffect,
useState,
} from "react";

interface DragDropFileInputProps {
title: string;
onChange?: (files: FileList) => void;
readonly?: boolean;
fileInputRef: MutableRefObject<HTMLInputElement | null>;
}

export const DragDropFileInput: React.FC<DragDropFileInputProps> = (props) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const fileInput = props.fileInputRef.current;
const [fileDropStyle, setFileDropStyle] = useState<CSSProperties>({});
const [lastTarget, setLastTarget] = useState<EventTarget | null>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
Expand Down Expand Up @@ -39,8 +45,7 @@ export const DragDropFileInput: React.FC<DragDropFileInputProps> = (props) => {
};
const fileChange = () => {
if (!props.readonly) {
const inp = fileInputRef.current;
const files = inp?.files;
const files = fileInput?.files;
if (props.onChange && files) {
props.onChange(files);
}
Expand All @@ -49,9 +54,8 @@ export const DragDropFileInput: React.FC<DragDropFileInputProps> = (props) => {
};
const onDrop = (e: React.DragEvent) => {
if (!props.readonly) {
const inp = fileInputRef.current;
if (inp) {
inp.files = e.dataTransfer.files;
if (fileInput) {
fileInput.files = e.dataTransfer.files;
}
if (props.onChange) {
props.onChange(e.dataTransfer.files);
Expand Down Expand Up @@ -92,7 +96,7 @@ export const DragDropFileInput: React.FC<DragDropFileInputProps> = (props) => {
type="file"
name="newfile"
style={{ display: "none" }}
ref={fileInputRef}
ref={props.fileInputRef}
onChange={fileChange}
disabled={props.readonly}
/>
Expand Down
53 changes: 47 additions & 6 deletions builder/src/upload.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Sentry from "@sentry/browser";
import React, { useEffect, useState } from "react";
import React, { useRef, useEffect, useState } from "react";
import {
Community,
ExperimentalApi,
Expand All @@ -19,6 +19,7 @@ import { FormSelectField } from "./components/FormSelectField";
import { CommunityCategorySelector } from "./components/CommunitySelector";
import { FormRow } from "./components/FormRow";
import { SubmitPackage } from "./api/packageSubmit";
import { validateZip } from "./uploadZipValidation";

function getUploadProgressBarcolor(uploadStatus: FileUploadStatus | undefined) {
if (uploadStatus == FileUploadStatus.CANCELED) {
Expand All @@ -42,17 +43,16 @@ function getSubmissionProgressBarcolor(
return "bg-warning";
}

class FormErrors {
fileError: string | null = null;
export class FormErrors {
teamError: string | null = null;
communitiesError: string | null = null;
categoriesError: string | null = null;
nsfwError: string | null = null;
generalErrors: string[] = [];
fileErrors: string[] = [];

get hasErrors(): boolean {
return !(
this.fileError == null &&
this.teamError == null &&
this.communitiesError == null &&
this.categoriesError == null &&
Expand Down Expand Up @@ -81,6 +81,7 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
const [formErrors, setFormErrors] = useState<FormErrors>(new FormErrors());
const [file, setFile] = useState<File | null>(null);
const [fileUpload, setFileUpload] = useState<FileUpload | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [
submissionStatus,
setSubmissionStatus,
Expand Down Expand Up @@ -115,6 +116,12 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
if (fileUpload) {
await fileUpload.cancelUpload();
}

const input = fileInputRef.current;
if (input) {
input.value = "";
}

setFileUpload(null);
setSubmissionStatus(null);
setFormErrors(new FormErrors());
Expand All @@ -130,13 +137,32 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
const onFileChange = (files: FileList) => {
const file = files.item(0);
setFile(file);

if (file) {
validateZip(file).then((result) => {
if (result.errors.fileErrors.length > 0) {
setFormErrors(result.errors);

if (result.blockUpload) {
result.errors.generalErrors.push(
"An error with your selected file is preventing submission."
);
setSubmissionStatus(SubmissionStatus.ERROR);
}
}
});
}
};

const onSubmit = async (data: any) => {
// TODO: Convert to react-hook-form validation

let fileErrors = formErrors.fileErrors;
setFormErrors(new FormErrors());
const errors = new FormErrors();

errors.fileErrors = fileErrors;

const uploadTeam = data.team ? data.team.value : null;
const uploadCommunities = data.communities
? data.communities.map((com: any) => com.value)
Expand Down Expand Up @@ -245,6 +271,8 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
(fileUpload?.uploadErrors ?? []).length > 0 ||
formErrors.generalErrors.length > 0;

const hasFileErrors = formErrors.fileErrors.length > 0;

const hasEtagError =
fileUpload &&
fileUpload.uploadErrors.some(
Expand Down Expand Up @@ -296,9 +324,18 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
title={file ? file.name : "Choose or drag file here"}
onChange={onFileChange}
readonly={!!file}
fileInputRef={fileInputRef}
/>
</div>

{hasFileErrors && (
<div className="mb-0 px-3 py-3 alert alert-info field-errors mt-2">
<ul className="mx-0 my-0 pl-3">
{formErrors.fileErrors.map((e, idx) => (
<li key={`general-${idx}`}>{e}</li>
))}
</ul>
</div>
)}
{currentCommunity != null &&
teams != null &&
communities != null ? (
Expand Down Expand Up @@ -371,7 +408,11 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {

<button
type={"submit"}
disabled={!file || !!fileUpload}
disabled={
!file ||
!!fileUpload ||
submissionStatus == SubmissionStatus.ERROR
}
className="btn btn-primary btn-block"
>
Submit
Expand Down
197 changes: 197 additions & 0 deletions builder/src/uploadZipValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { FormErrors } from "./upload";
import { BlobReader, ZipReader } from "./vendor/zip-fs-full";

export async function validateZip(
file: File
): Promise<{ errors: FormErrors; blockUpload: boolean }> {
let errors = new FormErrors();

let blockUpload = false;

if (!file.name.toLowerCase().endsWith(".zip")) {
errors.fileErrors.push("The file you selected is not a .zip!");
blockUpload = true;
return { errors, blockUpload };
}

try {
const blobReader = new BlobReader(file);
const zipReader = new ZipReader(blobReader);

const entries = await zipReader.getEntries();

let dllCount = 0;
let hasBepInEx = false;
let hasAssemblyCSharp = false;
let maybeModpack = false;
let rootManifest = false;
let hasIcon = false;
let rootIcon = false;
let hasManifest = false;
let hasReadMe = false;
let rootReadMe = false;
let wrongCase = false;
let wrongExtension = false;
let noExtension = false;
for (const entry of entries) {
console.log(entry.filename);
if (!entry || !(typeof entry.getData === "function")) {
continue;
}

if (entry.filename.toLowerCase().endsWith(".dll")) {
dllCount++;
}

if (
entry.filename.toLowerCase().split("/").pop() ==
"assembly-csharp.dll"
) {
hasAssemblyCSharp = true;
}

if (
entry.filename.toLowerCase().split("/").pop() == "bepinex.dll"
) {
hasBepInEx = true;
maybeModpack = true;
}

if (entry.filename.toLowerCase().endsWith("manifest.json")) {
hasManifest = true;
if (entry.filename == "manifest.json") {
rootManifest = true;
} else if (entry.filename.toLowerCase() == "manifest.json") {
wrongCase = true;
rootManifest = true;
}
}
if (entry.filename.toLowerCase().endsWith("icon.png")) {
hasIcon = true;
if (entry.filename == "icon.png") {
rootIcon = true;
} else if (entry.filename.toLowerCase() == "icon.png") {
wrongCase = true;
rootIcon = true;
}
}
if (entry.filename.toLowerCase().endsWith("readme.md")) {
hasReadMe = true;
if (entry.filename == "README.md") {
rootReadMe = true;
} else if (entry.filename.toLowerCase() == "readme.md") {
wrongCase = true;
rootReadMe = true;
}
}

if (
entry.filename.toLowerCase() == "readme.txt" ||
entry.filename.toLowerCase() == "manifest.txt" ||
entry.filename.toLowerCase() == "icon.jpg" ||
entry.filename.toLowerCase() == "icon.jpeg"
) {
wrongExtension = true;
}

if (
entry.filename.toLowerCase() == "readme" ||
entry.filename.toLowerCase() == "manifest" ||
entry.filename.toLowerCase() == "icon"
) {
noExtension = true;
}
}

if (hasBepInEx) {
errors.fileErrors.push(
"You have BepInEx.dll in your .zip file. BepInEx should probably be a dependency in your manifest.json file instead."
);
}

if (hasAssemblyCSharp) {
errors.fileErrors.push(
"You have Assembly-CSharp.dll in your .zip file. Your package may be removed if you do not have permission to distribute this file."
);
}

if (dllCount > 8) {
errors.fileErrors.push(
"You have " +
dllCount +
" .dll files in your .zip file. Some of these files may be unnecessary."
);
maybeModpack = true;
}

if (maybeModpack) {
errors.fileErrors.push(
"If you're making a modpack, do not include the files for each mod in your .zip file. Instead, put the dependency string for each mod inside your manifest.json file."
);
}

if (wrongCase) {
blockUpload = true;
errors.fileErrors.push(
"The file names of manifest.json, icon.png, and README.md are case-sensitive."
);
}

if (wrongExtension) {
blockUpload = true;
errors.fileErrors.push(
"Your manifest.json, icon.png, and README.md files must have the correct file extensions."
);
}

if (
hasManifest &&
hasIcon &&
hasReadMe &&
!rootManifest &&
!rootIcon &&
!rootReadMe
) {
blockUpload = true;
errors.fileErrors.push(
"Your manifest, icon, and README files should be at the root of the .zip file. You can prevent this by compressing the contents of a folder, rather than the folder itself."
);
} else {
if ((!hasManifest || !hasIcon || !hasReadMe) && noExtension) {
blockUpload = true;
errors.fileErrors.push(
"Your manifest.json, icon.png, or README.md file is missing its file extension."
);
}

if (!hasManifest) {
blockUpload = true;
errors.fileErrors.push(
"Your package is missing a manifest.json file!"
);
}

if (!hasIcon) {
blockUpload = true;
errors.fileErrors.push(
"Your package is missing an icon.png file!"
);
}

if (!hasReadMe) {
blockUpload = true;
errors.fileErrors.push(
"Your package is missing a README.md file!"
);
}
}

await zipReader.close();
} catch (e) {
blockUpload = true;
errors = new FormErrors();
errors.fileErrors.push("Your .zip file could not be read.");
}

return { errors, blockUpload };
}
Loading
Loading