Skip to content

Commit d27ceb8

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 39e4f1c commit d27ceb8

File tree

2 files changed

+244
-13
lines changed

2 files changed

+244
-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: 232 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,218 @@ 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
198+
.toLowerCase()
199+
.split("/")
200+
.pop() == "assembly-csharp.dll"
201+
) {
202+
hasAssemblyCSharp = true;
203+
}
204+
205+
if (
206+
entry.filename
207+
.toLowerCase()
208+
.split("/")
209+
.pop() == "bepinex.dll"
210+
) {
211+
hasBepInEx = true;
212+
maybeModpack = true;
213+
}
214+
215+
if (noRootFiles) {
216+
if (!entry.filename.includes("/")) {
217+
noRootFiles = false;
218+
}
219+
}
220+
if (
221+
entry.filename.toLowerCase().endsWith("manifest.json")
222+
) {
223+
hasManifest = true;
224+
if (entry.filename.toLowerCase() == "manifest.json") {
225+
rootManifest = true;
226+
}
227+
}
228+
if (entry.filename.toLowerCase().endsWith("icon.png")) {
229+
hasIcon = true;
230+
if (entry.filename.toLowerCase() == "icon.png") {
231+
rootIcon = true;
232+
}
233+
}
234+
if (entry.filename.toLowerCase().endsWith("readme.md")) {
235+
hasReadMe = true;
236+
if (entry.filename.toLowerCase() == "readme.md") {
237+
rootReadMe = true;
238+
}
239+
}
240+
}
241+
242+
if(hasBepInEx) {
243+
errors.fileErrors.push(
244+
"You have BepInEx.dll in your .zip file. BepInEx should probably be a dependency in your manifest.json file instead."
245+
);
246+
}
247+
248+
if (hasAssemblyCSharp) {
249+
errors.fileErrors.push(
250+
"You have Assembly-CSharp.dll in your .zip file. Your mod may be removed if you do not have permission to distribute this file."
251+
);
252+
}
253+
254+
if (dllCount > 8) {
255+
errors.fileErrors.push(
256+
"You have " +
257+
dllCount +
258+
" .dll files in your .zip file. Some of these files may be unnecessary."
259+
);
260+
maybeModpack = true;
261+
}
262+
263+
if (maybeModpack) {
264+
errors.fileErrors.push(
265+
"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."
266+
);
267+
}
268+
269+
if (
270+
noRootFiles &&
271+
hasManifest &&
272+
hasIcon &&
273+
hasReadMe &&
274+
!rootManifest &&
275+
!rootIcon &&
276+
!rootReadMe
277+
) {
278+
blockUpload = true;
279+
errors.fileErrors.push(
280+
"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."
281+
);
282+
} else {
283+
if (!hasManifest) {
284+
blockUpload = true;
285+
errors.fileErrors.push(
286+
"Your package is missing a manifest.json file!"
287+
);
288+
} else if (!rootManifest) {
289+
blockUpload = true;
290+
errors.fileErrors.push(
291+
"Your manifest.json file is not at the root of the .zip!"
292+
);
293+
}
294+
295+
if (!hasIcon) {
296+
blockUpload = true;
297+
errors.fileErrors.push(
298+
"Your package is missing an icon.png file!"
299+
);
300+
} else if (!rootIcon) {
301+
blockUpload = true;
302+
errors.fileErrors.push(
303+
"Your icon.png file is not at the root of the .zip!"
304+
);
305+
}
306+
307+
if (!hasReadMe) {
308+
blockUpload = true;
309+
errors.fileErrors.push(
310+
"Your package is missing a README.md file!"
311+
);
312+
} else if (!rootReadMe) {
313+
blockUpload = true;
314+
errors.fileErrors.push(
315+
"Your README.md file is not at the root of the .zip!"
316+
);
317+
}
318+
}
319+
320+
await zipReader.close();
321+
} catch (e) {
322+
console.log("Error reading zip: " + e);
323+
return false;
324+
}
325+
}
326+
327+
if (errors.fileErrors.length > 0) {
328+
setFormErrors(errors);
329+
330+
if (blockUpload) {
331+
errors.generalErrors.push(
332+
"An error with your selected file is preventing submission."
333+
);
334+
setSubmissionStatus(SubmissionStatus.ERROR);
335+
return false;
336+
}
337+
return true;
338+
} else {
339+
return true;
340+
}
341+
}
342+
135343
const onSubmit = async (data: any) => {
136344
// TODO: Convert to react-hook-form validation
345+
346+
let fileErrors = formErrors.fileErrors;
137347
setFormErrors(new FormErrors());
138348
const errors = new FormErrors();
139349

350+
errors.fileErrors = fileErrors;
351+
140352
const uploadTeam = data.team ? data.team.value : null;
141353
const uploadCommunities = data.communities
142354
? data.communities.map((com: any) => com.value)
@@ -245,6 +457,8 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
245457
(fileUpload?.uploadErrors ?? []).length > 0 ||
246458
formErrors.generalErrors.length > 0;
247459

460+
const hasFileErrors = formErrors.fileErrors.length > 0;
461+
248462
const hasEtagError =
249463
fileUpload &&
250464
fileUpload.uploadErrors.some(
@@ -296,9 +510,18 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
296510
title={file ? file.name : "Choose or drag file here"}
297511
onChange={onFileChange}
298512
readonly={!!file}
513+
fileInputRef={fileInputRef}
299514
/>
300515
</div>
301-
516+
{hasFileErrors && (
517+
<div className="mb-0 px-3 py-3 alert alert-info field-errors mt-2">
518+
<ul className="mx-0 my-0 pl-3">
519+
{formErrors.fileErrors.map((e, idx) => (
520+
<li key={`general-${idx}`}>{e}</li>
521+
))}
522+
</ul>
523+
</div>
524+
)}
302525
{currentCommunity != null &&
303526
teams != null &&
304527
communities != null ? (
@@ -371,7 +594,11 @@ const SubmissionForm: React.FC<SubmissionFormProps> = observer((props) => {
371594

372595
<button
373596
type={"submit"}
374-
disabled={!file || !!fileUpload}
597+
disabled={
598+
!file ||
599+
!!fileUpload ||
600+
submissionStatus == SubmissionStatus.ERROR
601+
}
375602
className="btn btn-primary btn-block"
376603
>
377604
Submit

0 commit comments

Comments
 (0)