Skip to content

Commit aa181e6

Browse files
committed
Add Client-Side Pre-Upload Package Validation
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.
1 parent 636207b commit aa181e6

File tree

2 files changed

+240
-13
lines changed

2 files changed

+240
-13
lines changed

builder/src/components/DragDropFileInput.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
1-
import React, { CSSProperties, useEffect, useRef, useState } from "react";
1+
import React, {
2+
CSSProperties,
3+
MutableRefObject,
4+
useEffect,
5+
useState,
6+
} from "react";
27

38
interface DragDropFileInputProps {
49
title: string;
510
onChange?: (files: FileList) => void;
611
readonly?: boolean;
12+
fileInputRef: MutableRefObject<HTMLInputElement | null>;
713
}
814

915
export const DragDropFileInput: React.FC<DragDropFileInputProps> = (props) => {
10-
const fileInputRef = useRef<HTMLInputElement>(null);
16+
const fileInput = props.fileInputRef.current;
1117
const [fileDropStyle, setFileDropStyle] = useState<CSSProperties>({});
1218
const [lastTarget, setLastTarget] = useState<EventTarget | null>(null);
1319
const [isDragging, setIsDragging] = useState<boolean>(false);
@@ -39,8 +45,7 @@ export const DragDropFileInput: React.FC<DragDropFileInputProps> = (props) => {
3945
};
4046
const fileChange = () => {
4147
if (!props.readonly) {
42-
const inp = fileInputRef.current;
43-
const files = inp?.files;
48+
const files = fileInput?.files;
4449
if (props.onChange && files) {
4550
props.onChange(files);
4651
}
@@ -49,9 +54,8 @@ export const DragDropFileInput: React.FC<DragDropFileInputProps> = (props) => {
4954
};
5055
const onDrop = (e: React.DragEvent) => {
5156
if (!props.readonly) {
52-
const inp = fileInputRef.current;
53-
if (inp) {
54-
inp.files = e.dataTransfer.files;
57+
if (fileInput) {
58+
fileInput.files = e.dataTransfer.files;
5559
}
5660
if (props.onChange) {
5761
props.onChange(e.dataTransfer.files);
@@ -92,7 +96,7 @@ export const DragDropFileInput: React.FC<DragDropFileInputProps> = (props) => {
9296
type="file"
9397
name="newfile"
9498
style={{ display: "none" }}
95-
ref={fileInputRef}
99+
ref={props.fileInputRef}
96100
onChange={fileChange}
97101
disabled={props.readonly}
98102
/>

builder/src/upload.tsx

Lines changed: 228 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Sentry from "@sentry/browser";
2-
import React, { useEffect, useState } from "react";
2+
import React, { useRef, useEffect, useState } from "react";
33
import {
44
Community,
55
ExperimentalApi,
@@ -19,6 +19,7 @@ import { FormSelectField } from "./components/FormSelectField";
1919
import { CommunityCategorySelector } from "./components/CommunitySelector";
2020
import { FormRow } from "./components/FormRow";
2121
import { SubmitPackage } from "./api/packageSubmit";
22+
import { BlobReader, ZipReader } from "./vendor/zip-fs-full";
2223

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

4546
class FormErrors {
46-
fileError: string | null = null;
4747
teamError: string | null = null;
4848
communitiesError: string | null = null;
4949
categoriesError: string | null = null;
5050
nsfwError: string | null = null;
5151
generalErrors: string[] = [];
52+
fileErrors: string[] = [];
5253

5354
get hasErrors(): boolean {
5455
return !(
55-
this.fileError == null &&
5656
this.teamError == null &&
5757
this.communitiesError == null &&
5858
this.categoriesError == null &&
@@ -81,6 +81,7 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
8181
const [formErrors, setFormErrors] = useState<FormErrors>(new FormErrors());
8282
const [file, setFile] = useState<File | null>(null);
8383
const [fileUpload, setFileUpload] = useState<FileUpload | null>(null);
84+
const fileInputRef = useRef<HTMLInputElement | null>(null);
8485
const [
8586
submissionStatus,
8687
setSubmissionStatus,
@@ -115,6 +116,12 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
115116
if (fileUpload) {
116117
await fileUpload.cancelUpload();
117118
}
119+
120+
const input = fileInputRef.current;
121+
if (input) {
122+
input.value = "";
123+
}
124+
118125
setFileUpload(null);
119126
setSubmissionStatus(null);
120127
setFormErrors(new FormErrors());
@@ -130,13 +137,214 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
130137
const onFileChange = (files: FileList) => {
131138
const file = files.item(0);
132139
setFile(file);
140+
141+
if (file) {
142+
validateZip(file).then((result) => {
143+
if (result) {
144+
console.log("Zip successfully validated.");
145+
} else {
146+
console.log("Failed to validate zip.");
147+
}
148+
});
149+
}
133150
};
134151

152+
async function validateZip(file: File): Promise<boolean> {
153+
console.log("Selected file: " + file.name);
154+
155+
let errors = new FormErrors();
156+
157+
let blockUpload = false;
158+
159+
let isZip = true;
160+
if (!file.name.toLowerCase().endsWith(".zip")) {
161+
errors.fileErrors.push("The file you selected is not a .zip!");
162+
isZip = false;
163+
blockUpload = true;
164+
}
165+
166+
if (isZip) {
167+
try {
168+
const blobReader = new BlobReader(file);
169+
const zipReader = new ZipReader(blobReader);
170+
171+
const entries = await zipReader.getEntries();
172+
173+
let dllCount = 0;
174+
let hasBepInEx = false;
175+
let hasAssemblyCSharp = false;
176+
let maybeModpack = false;
177+
let noRootFiles = true;
178+
let rootManifest = false;
179+
let hasIcon = false;
180+
let rootIcon = false;
181+
let hasManifest = false;
182+
let hasReadMe = false;
183+
let rootReadMe = false;
184+
185+
for (const entry of entries) {
186+
// console.log(entry.filename);
187+
188+
if (!entry || !(typeof entry.getData === "function")) {
189+
continue;
190+
}
191+
192+
if (entry.filename.toLowerCase().endsWith(".dll")) {
193+
dllCount++;
194+
}
195+
196+
if (
197+
entry.filename.toLowerCase().split("/").pop() ==
198+
"assembly-csharp.dll"
199+
) {
200+
hasAssemblyCSharp = true;
201+
}
202+
203+
if (
204+
entry.filename.toLowerCase().split("/").pop() ==
205+
"bepinex.dll"
206+
) {
207+
hasBepInEx = true;
208+
maybeModpack = true;
209+
}
210+
211+
if (noRootFiles) {
212+
if (!entry.filename.includes("/")) {
213+
noRootFiles = false;
214+
}
215+
}
216+
if (
217+
entry.filename.toLowerCase().endsWith("manifest.json")
218+
) {
219+
hasManifest = true;
220+
if (entry.filename.toLowerCase() == "manifest.json") {
221+
rootManifest = true;
222+
}
223+
}
224+
if (entry.filename.toLowerCase().endsWith("icon.png")) {
225+
hasIcon = true;
226+
if (entry.filename.toLowerCase() == "icon.png") {
227+
rootIcon = true;
228+
}
229+
}
230+
if (entry.filename.toLowerCase().endsWith("readme.md")) {
231+
hasReadMe = true;
232+
if (entry.filename.toLowerCase() == "readme.md") {
233+
rootReadMe = true;
234+
}
235+
}
236+
}
237+
238+
if (hasBepInEx) {
239+
errors.fileErrors.push(
240+
"You have BepInEx.dll in your .zip file. BepInEx should probably be a dependency in your manifest.json file instead."
241+
);
242+
}
243+
244+
if (hasAssemblyCSharp) {
245+
errors.fileErrors.push(
246+
"You have Assembly-CSharp.dll in your .zip file. Your mod may be removed if you do not have permission to distribute this file."
247+
);
248+
}
249+
250+
if (dllCount > 8) {
251+
errors.fileErrors.push(
252+
"You have " +
253+
dllCount +
254+
" .dll files in your .zip file. Some of these files may be unnecessary."
255+
);
256+
maybeModpack = true;
257+
}
258+
259+
if (maybeModpack) {
260+
errors.fileErrors.push(
261+
"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."
262+
);
263+
}
264+
265+
if (
266+
noRootFiles &&
267+
hasManifest &&
268+
hasIcon &&
269+
hasReadMe &&
270+
!rootManifest &&
271+
!rootIcon &&
272+
!rootReadMe
273+
) {
274+
blockUpload = true;
275+
errors.fileErrors.push(
276+
"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."
277+
);
278+
} else {
279+
if (!hasManifest) {
280+
blockUpload = true;
281+
errors.fileErrors.push(
282+
"Your package is missing a manifest.json file!"
283+
);
284+
} else if (!rootManifest) {
285+
blockUpload = true;
286+
errors.fileErrors.push(
287+
"Your manifest.json file is not at the root of the .zip!"
288+
);
289+
}
290+
291+
if (!hasIcon) {
292+
blockUpload = true;
293+
errors.fileErrors.push(
294+
"Your package is missing an icon.png file!"
295+
);
296+
} else if (!rootIcon) {
297+
blockUpload = true;
298+
errors.fileErrors.push(
299+
"Your icon.png file is not at the root of the .zip!"
300+
);
301+
}
302+
303+
if (!hasReadMe) {
304+
blockUpload = true;
305+
errors.fileErrors.push(
306+
"Your package is missing a README.md file!"
307+
);
308+
} else if (!rootReadMe) {
309+
blockUpload = true;
310+
errors.fileErrors.push(
311+
"Your README.md file is not at the root of the .zip!"
312+
);
313+
}
314+
}
315+
316+
await zipReader.close();
317+
} catch (e) {
318+
console.log("Error reading zip: " + e);
319+
return false;
320+
}
321+
}
322+
323+
if (errors.fileErrors.length > 0) {
324+
setFormErrors(errors);
325+
326+
if (blockUpload) {
327+
errors.generalErrors.push(
328+
"An error with your selected file is preventing submission."
329+
);
330+
setSubmissionStatus(SubmissionStatus.ERROR);
331+
return false;
332+
}
333+
return true;
334+
} else {
335+
return true;
336+
}
337+
}
338+
135339
const onSubmit = async (data: any) => {
136340
// TODO: Convert to react-hook-form validation
341+
342+
let fileErrors = formErrors.fileErrors;
137343
setFormErrors(new FormErrors());
138344
const errors = new FormErrors();
139345

346+
errors.fileErrors = fileErrors;
347+
140348
const uploadTeam = data.team ? data.team.value : null;
141349
const uploadCommunities = data.communities
142350
? data.communities.map((com: any) => com.value)
@@ -245,6 +453,8 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
245453
(fileUpload?.uploadErrors ?? []).length > 0 ||
246454
formErrors.generalErrors.length > 0;
247455

456+
const hasFileErrors = formErrors.fileErrors.length > 0;
457+
248458
const hasEtagError =
249459
fileUpload &&
250460
fileUpload.uploadErrors.some(
@@ -296,9 +506,18 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
296506
title={file ? file.name : "Choose or drag file here"}
297507
onChange={onFileChange}
298508
readonly={!!file}
509+
fileInputRef={fileInputRef}
299510
/>
300511
</div>
301-
512+
{hasFileErrors && (
513+
<div className="mb-0 px-3 py-3 alert alert-info field-errors mt-2">
514+
<ul className="mx-0 my-0 pl-3">
515+
{formErrors.fileErrors.map((e, idx) => (
516+
<li key={`general-${idx}`}>{e}</li>
517+
))}
518+
</ul>
519+
</div>
520+
)}
302521
{currentCommunity != null &&
303522
teams != null &&
304523
communities != null ? (
@@ -371,7 +590,11 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
371590

372591
<button
373592
type={"submit"}
374-
disabled={!file || !!fileUpload}
593+
disabled={
594+
!file ||
595+
!!fileUpload ||
596+
submissionStatus == SubmissionStatus.ERROR
597+
}
375598
className="btn btn-primary btn-block"
376599
>
377600
Submit

0 commit comments

Comments
 (0)