Skip to content

Commit

Permalink
Add Client-Side Pre-Upload Package Validation
Browse files Browse the repository at this point in the history
Prevents users from attempting to upload packages that aren't zips or
packages without a manifest, icon, and readme in the root of the zip.

Warns the user when uploading packages with > 8 DLL files, an
Assembly-CSharp.dll file, or BepInEx.dll.

Fixes a chromium bug preventing the selection of a file that was
previously selected and canceled.
  • Loading branch information
x753 committed Sep 9, 2024
1 parent 39e4f1c commit d27ceb8
Show file tree
Hide file tree
Showing 2 changed files with 244 additions and 13 deletions.
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
237 changes: 232 additions & 5 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 { BlobReader, ZipReader } from "./vendor/zip-fs-full";

function getUploadProgressBarcolor(uploadStatus: FileUploadStatus | undefined) {
if (uploadStatus == FileUploadStatus.CANCELED) {
Expand All @@ -43,16 +44,15 @@ function getSubmissionProgressBarcolor(
}

class FormErrors {
fileError: string | null = null;
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,218 @@ 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) {
console.log("Zip successfully validated.");
} else {
console.log("Failed to validate zip.");
}
});
}
};

async function validateZip(file: File): Promise<boolean> {
console.log("Selected file: " + file.name);

let errors = new FormErrors();

let blockUpload = false;

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

if (isZip) {
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 noRootFiles = true;
let rootManifest = false;
let hasIcon = false;
let rootIcon = false;
let hasManifest = false;
let hasReadMe = false;
let rootReadMe = 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 (noRootFiles) {
if (!entry.filename.includes("/")) {
noRootFiles = false;
}
}
if (
entry.filename.toLowerCase().endsWith("manifest.json")
) {
hasManifest = true;
if (entry.filename.toLowerCase() == "manifest.json") {
rootManifest = true;
}
}
if (entry.filename.toLowerCase().endsWith("icon.png")) {
hasIcon = true;
if (entry.filename.toLowerCase() == "icon.png") {
rootIcon = true;
}
}
if (entry.filename.toLowerCase().endsWith("readme.md")) {
hasReadMe = true;
if (entry.filename.toLowerCase() == "readme.md") {
rootReadMe = 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 mod 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 (
noRootFiles &&
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) {
blockUpload = true;
errors.fileErrors.push(
"Your package is missing a manifest.json file!"
);
} else if (!rootManifest) {
blockUpload = true;
errors.fileErrors.push(
"Your manifest.json file is not at the root of the .zip!"
);
}

if (!hasIcon) {
blockUpload = true;
errors.fileErrors.push(
"Your package is missing an icon.png file!"
);
} else if (!rootIcon) {
blockUpload = true;
errors.fileErrors.push(
"Your icon.png file is not at the root of the .zip!"
);
}

if (!hasReadMe) {
blockUpload = true;
errors.fileErrors.push(
"Your package is missing a README.md file!"
);
} else if (!rootReadMe) {
blockUpload = true;
errors.fileErrors.push(
"Your README.md file is not at the root of the .zip!"
);
}
}

await zipReader.close();
} catch (e) {
console.log("Error reading zip: " + e);
return false;
}
}

if (errors.fileErrors.length > 0) {
setFormErrors(errors);

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

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 +457,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 +510,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 +594,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

0 comments on commit d27ceb8

Please sign in to comment.