+
}
>
- {(resolvedValue) =>
- resolvedValue && resolvedValue.html ? (
-
+ {(resolvedValue) => (
+
+
+ {resolvedValue?.html ? (
-
- ) : (
-
- )
- }
+ ) : (
+
+ )}
+
+ )}
);
diff --git a/apps/cyberstorm-remix/app/p/tabs/Readme/Readme.tsx b/apps/cyberstorm-remix/app/p/tabs/Readme/Readme.tsx
index 748dc513f..fb0e253f0 100644
--- a/apps/cyberstorm-remix/app/p/tabs/Readme/Readme.tsx
+++ b/apps/cyberstorm-remix/app/p/tabs/Readme/Readme.tsx
@@ -1,4 +1,5 @@
import { FetchErrorState } from "app/commonComponents/FetchErrorState/FetchErrorState";
+import { MarkdownEditedNote } from "app/p/components/MarkdownEditedNote/MarkdownEditedNote";
import { TabFetchState } from "app/p/components/TabFetchState/TabFetchState";
import { getSessionTools } from "cyberstorm/security/publicEnvVariables";
import { getApiHostForSsr } from "cyberstorm/utils/env";
@@ -85,18 +86,19 @@ export default function Readme() {
resolve={readme}
errorElement={
}
>
- {(resolvedValue) =>
- resolvedValue && resolvedValue.html ? (
-
+ {(resolvedValue) => (
+
+
+ {resolvedValue?.html ? (
-
- ) : (
-
- )
- }
+ ) : (
+
+ )}
+
+ )}
);
diff --git a/apps/cyberstorm-remix/app/p/tabs/__tests__/markdownDisclosure.test.ts b/apps/cyberstorm-remix/app/p/tabs/__tests__/markdownDisclosure.test.ts
new file mode 100644
index 000000000..592abafd4
--- /dev/null
+++ b/apps/cyberstorm-remix/app/p/tabs/__tests__/markdownDisclosure.test.ts
@@ -0,0 +1,73 @@
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { RouterProvider, createMemoryRouter } from "react-router";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import PackageVersionReadme from "../Readme/PackageVersionReadme";
+import Readme from "../Readme/Readme";
+
+(
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
+).IS_REACT_ACT_ENVIRONMENT = true;
+
+vi.mock("cyberstorm/utils/ssrLoader", () => ({
+ ssrLoader: (loader: unknown) => loader,
+ forwardLoaderHeaders: vi.fn(),
+}));
+vi.mock("@thunderstore/dapper-ts", () => ({ DapperTs: vi.fn() }));
+vi.mock("app/commonComponents/FetchErrorState/FetchErrorState", () => ({
+ FetchErrorState: () => null,
+}));
+vi.mock("app/p/components/TabFetchState/TabFetchState", () => ({
+ TabFetchState: () => null,
+}));
+vi.mock("@thunderstore/cyberstorm", () => ({
+ SkeletonBox: () => null,
+ LocalDateTime: ({ time }: { time: string }) => time,
+}));
+
+let cleanup: (() => void) | undefined;
+
+async function renderPage(component: React.ComponentType, data: unknown) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const router = createMemoryRouter([
+ { path: "/", loader: () => data, element: React.createElement(component) },
+ ]);
+ await act(async () => {
+ root.render(React.createElement(RouterProvider, { router }));
+ });
+ cleanup = () => {
+ act(() => root.unmount());
+ router.dispose();
+ container.remove();
+ };
+ return container;
+}
+
+afterEach(() => {
+ cleanup?.();
+});
+
+const edited_at = "2026-09-08T00:30:00Z";
+
+describe("edited Markdown disclosure", () => {
+ it.each([
+ ["latest", Readme],
+ ["historical", PackageVersionReadme],
+ ])("marks an empty %s README as edited", async (_, component) => {
+ const container = await renderPage(component, {
+ readme: { html: "", is_edited: true, edited_at },
+ });
+ expect(container.textContent).toContain("Edited");
+ expect(container.textContent).toContain(edited_at);
+ });
+
+ it("does not mark an unedited README", async () => {
+ const container = await renderPage(Readme, {
+ readme: { html: "
x
", is_edited: false, edited_at: null },
+ });
+ expect(container.textContent).not.toContain("Edited");
+ });
+});
diff --git a/apps/cyberstorm-remix/app/root.tsx b/apps/cyberstorm-remix/app/root.tsx
index 2c989d068..6bf3501fd 100644
--- a/apps/cyberstorm-remix/app/root.tsx
+++ b/apps/cyberstorm-remix/app/root.tsx
@@ -272,8 +272,12 @@ export function Layout({ children }: { children: React.ReactNode }) {
// Routes where the ad surface is allowed at all. The NitroPay script (and its
// consent banner) loads on these; account / login / upload / tools routes get
// neither. (/auth is backend-proxied, so the app never renders it.)
+ const isReadmeEditPage = matches.some(
+ (m) => m.id === "p/readmeEdit/ReadmeEdit"
+ );
const adsAllowedOnRoute =
!adsDisabled &&
+ !isReadmeEditPage &&
!["/teams", "/settings", "/package/create", "/tools", "/login"].some(
(prefix) => location.pathname.startsWith(prefix)
);
diff --git a/apps/cyberstorm-remix/app/routes.ts b/apps/cyberstorm-remix/app/routes.ts
index 6cc439f4e..167089b31 100644
--- a/apps/cyberstorm-remix/app/routes.ts
+++ b/apps/cyberstorm-remix/app/routes.ts
@@ -73,6 +73,10 @@ export default [
]
),
*/
+ route(
+ "/c/:communityId/p/:namespaceId/:packageId/v/:packageVersion/readme/edit",
+ "p/readmeEdit/ReadmeEdit.tsx"
+ ),
route(
"/c/:communityId/p/:namespaceId/:packageId/dependants",
"p/dependants/Dependants.tsx"
diff --git a/apps/cyberstorm-remix/app/upload/__tests__/readZipEntryText.test.ts b/apps/cyberstorm-remix/app/upload/__tests__/readZipEntryText.test.ts
new file mode 100644
index 000000000..59381d8fe
--- /dev/null
+++ b/apps/cyberstorm-remix/app/upload/__tests__/readZipEntryText.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { readZipEntryText, readZipFilenames } from "../readZipFilenames";
+import { deflatedEntry, storedEntry, zipFile } from "./zipFixtures";
+
+const MANIFEST = JSON.stringify({ name: "MyMod", version_number: "1.0.0" });
+
+describe("readZipEntryText", () => {
+ it("reads a stored entry", async () => {
+ const file = zipFile(["manifest.json", "README.md"], "package.zip", {
+ "manifest.json": storedEntry(MANIFEST),
+ });
+ await expect(readZipEntryText(file, "manifest.json")).resolves.toBe(
+ MANIFEST
+ );
+ });
+
+ it("matches the entry name exactly", async () => {
+ const file = zipFile(["Manifest.json"], "package.zip", {
+ "Manifest.json": storedEntry(MANIFEST),
+ });
+ await expect(readZipEntryText(file, "manifest.json")).resolves.toBeNull();
+ });
+
+ it("returns null for entries above the size cap", async () => {
+ const file = zipFile(["manifest.json"], "package.zip", {
+ "manifest.json": await deflatedEntry("x".repeat(1024 * 1024 + 1)),
+ });
+ await expect(readZipEntryText(file, "manifest.json")).resolves.toBeNull();
+ });
+
+ it("accepts a deflated entry exactly at the size cap", async () => {
+ const text = "x".repeat(1024 * 1024);
+ const file = zipFile(["manifest.json"], "package.zip", {
+ "manifest.json": await deflatedEntry(text),
+ });
+ await expect(readZipEntryText(file, "manifest.json")).resolves.toBe(text);
+ });
+
+ it("reads metadata without buffering the package payload", async () => {
+ const file = zipFile(["manifest.json", "payload.bin"], "package.zip", {
+ "manifest.json": storedEntry(MANIFEST),
+ "payload.bin": storedEntry("x".repeat(2 * 1024 * 1024)),
+ });
+ const wholeFileRead = vi.spyOn(file, "arrayBuffer");
+ const slices = vi.spyOn(file, "slice");
+ try {
+ await expect(readZipFilenames(file)).resolves.toEqual([
+ "manifest.json",
+ "payload.bin",
+ ]);
+ await expect(readZipEntryText(file, "manifest.json")).resolves.toBe(
+ MANIFEST
+ );
+ expect(wholeFileRead).not.toHaveBeenCalled();
+ const bytesRead = slices.mock.results.reduce(
+ (size, result) => size + result.value.size,
+ 0
+ );
+ expect(bytesRead).toBeLessThan(192 * 1024);
+ } finally {
+ wholeFileRead.mockRestore();
+ slices.mockRestore();
+ }
+ });
+
+ it("rejects content with an invalid checksum", async () => {
+ const entry = storedEntry(MANIFEST);
+ const file = zipFile(["manifest.json"], "package.zip", {
+ "manifest.json": { ...entry, crc32: entry.crc32! ^ 1 },
+ });
+ await expect(readZipEntryText(file, "manifest.json")).resolves.toBeNull();
+ });
+
+ it("returns null for something that is not a ZIP", async () => {
+ const file = new File(["zip"], "package.zip", { type: "application/zip" });
+ await expect(readZipEntryText(file, "manifest.json")).resolves.toBeNull();
+ });
+});
diff --git a/apps/cyberstorm-remix/app/upload/__tests__/readmeCarry.test.ts b/apps/cyberstorm-remix/app/upload/__tests__/readmeCarry.test.ts
new file mode 100644
index 000000000..e2739d2a0
--- /dev/null
+++ b/apps/cyberstorm-remix/app/upload/__tests__/readmeCarry.test.ts
@@ -0,0 +1,192 @@
+import { getByRole } from "@testing-library/dom";
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { Outlet, RouterProvider, createMemoryRouter } from "react-router";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import Upload from "../upload";
+
+(
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
+).IS_REACT_ACT_ENVIRONMENT = true;
+
+vi.mock("../../root", () => ({}));
+vi.mock("cyberstorm/utils/ssrLoader", () => ({
+ ssrLoader: (loader: unknown) => loader,
+}));
+vi.mock("cyberstorm/utils/StrongForm/useStrongForm", () => ({
+ useStrongForm: () => ({ submitting: true, submit: vi.fn() }),
+}));
+vi.mock("@thunderstore/dapper-ts", () => ({
+ DapperTs: vi.fn(),
+ postPackageSubmissionMetadata: vi.fn(),
+}));
+vi.mock("@thunderstore/ts-uploader-react", () => ({
+ useUploadProgress: () => undefined,
+}));
+vi.mock("../../commonComponents/ErrorBoundary/RouteErrorBoundary", () => ({
+ RouteErrorBoundary: () => null,
+}));
+vi.mock("../../commonComponents/Page/Page", () => ({
+ Page: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock("../../commonComponents/PageHeader/PageHeader", () => ({
+ PageHeader: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock("../../commonComponents/FormSection/FormSection", () => ({
+ FormSections: ({ children }: { children: React.ReactNode }) => children,
+ FormSectionSeparator: () => null,
+}));
+vi.mock("@thunderstore/cyberstorm", () => ({
+ NewAlert: ({ children }: { children: React.ReactNode }) => children,
+ NewLink: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock("../components/UploadFileSection", () => ({
+ UploadFileSection: ({
+ carryOverride,
+ onCarryOverrideChange,
+ }: {
+ carryOverride: boolean;
+ onCarryOverrideChange: (value: boolean) => void;
+ }) =>
+ React.createElement("button", {
+ role: "switch",
+ "aria-checked": carryOverride,
+ onClick: () => onCarryOverrideChange(!carryOverride),
+ }),
+}));
+vi.mock("../uploadHooks", () => ({
+ usePackageFileUpload: () => ({
+ file: null,
+ fileWarnings: [],
+ fileErrors: [],
+ selectFile: vi.fn(),
+ clearFile: vi.fn(),
+ fileInputRef: { current: null },
+ }),
+ usePreviousOverrideWarning: () => ({
+ versionNumber: "1.0.1",
+ markdown: "Edited README",
+ }),
+ useSubmissionStatusPolling: () => ({
+ pollingError: null,
+ setPollingError: vi.fn(),
+ retryPolling: vi.fn(),
+ }),
+ useUploadCategoryOptions: () => ({}),
+}));
+vi.mock("../components/UploadTeamSection", () => ({
+ UploadTeamSection: ({
+ authorName,
+ onAuthorNameChange,
+ }: {
+ authorName: string;
+ onAuthorNameChange: (name: string) => void;
+ }) =>
+ React.createElement(
+ "select",
+ {
+ "aria-label": "Team",
+ value: authorName,
+ onChange: (event: React.ChangeEvent
) =>
+ onAuthorNameChange(event.target.value),
+ },
+ React.createElement("option", { value: "" }, "Select team"),
+ React.createElement("option", { value: "TeamA" }, "Team A"),
+ React.createElement("option", { value: "TeamB" }, "Team B")
+ ),
+}));
+vi.mock("../components/UploadCategoriesSection", () => ({
+ UploadCategoriesSection: () => null,
+}));
+vi.mock("../components/UploadCommunitiesSection", () => ({
+ UploadCommunitiesSection: () => null,
+}));
+vi.mock("../components/UploadNsfwSection", () => ({
+ UploadNsfwSection: () => null,
+}));
+vi.mock("../components/UploadSubmissionStatus", () => ({
+ UploadSubmissionStatus: ({ overrideToCarry }: { overrideToCarry: unknown }) =>
+ React.createElement("output", null, JSON.stringify(overrideToCarry)),
+}));
+vi.mock("../components/UploadSubmitSection", () => ({
+ UploadSubmitSection: ({ onSubmit }: { onSubmit: () => void }) =>
+ React.createElement("button", { onClick: onSubmit }, "Submit"),
+}));
+
+let cleanup: (() => void) | undefined;
+
+async function renderUpload() {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ element: React.createElement(Outlet, {
+ context: { requestConfig: () => ({}), currentUser: { teams_full: [] } },
+ }),
+ children: [
+ {
+ index: true,
+ loader: () => ({ results: [] }),
+ element: React.createElement(Upload),
+ },
+ ],
+ },
+ ]);
+ await act(async () => {
+ root.render(React.createElement(RouterProvider, { router }));
+ });
+ cleanup = () => {
+ act(() => root.unmount());
+ router.dispose();
+ container.remove();
+ };
+ return container;
+}
+
+afterEach(() => cleanup?.());
+
+describe("README carry-forward opt-in", () => {
+ it("resets the opt-in when the selected team changes", async () => {
+ const container = await renderUpload();
+ const team = getByRole(container, "combobox", {
+ name: "Team",
+ }) as HTMLSelectElement;
+ await act(async () => {
+ team.value = "TeamA";
+ team.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ const carry = getByRole(container, "switch");
+ await act(async () => carry.click());
+ expect(carry.getAttribute("aria-checked")).toBe("true");
+ await act(async () => {
+ team.value = "TeamB";
+ team.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ expect(carry.getAttribute("aria-checked")).toBe("false");
+ });
+
+ it("carries the override the form showed at submit", async () => {
+ const container = await renderUpload();
+ const carry = getByRole(container, "switch");
+ await act(async () => carry.click());
+ await act(async () =>
+ getByRole(container, "button", { name: "Submit" }).click()
+ );
+ await act(async () => carry.click());
+ expect(container.querySelector("output")?.textContent).toBe(
+ JSON.stringify({ versionNumber: "1.0.1", markdown: "Edited README" })
+ );
+ });
+
+ it("ignores switch changes after submitting", async () => {
+ const container = await renderUpload();
+ await act(async () =>
+ getByRole(container, "button", { name: "Submit" }).click()
+ );
+ await act(async () => getByRole(container, "switch").click());
+ expect(container.querySelector("output")?.textContent).toBe("null");
+ });
+});
diff --git a/apps/cyberstorm-remix/app/upload/__tests__/uploadHooks.test.ts b/apps/cyberstorm-remix/app/upload/__tests__/uploadHooks.test.ts
index 6203d808d..9820a7b4e 100644
--- a/apps/cyberstorm-remix/app/upload/__tests__/uploadHooks.test.ts
+++ b/apps/cyberstorm-remix/app/upload/__tests__/uploadHooks.test.ts
@@ -5,8 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { PackageSubmissionStatus } from "@thunderstore/dapper/types";
+import type { PreviousOverride } from "../../p/readmeEdit/overrideMigration";
import {
usePackageFileUpload,
+ usePreviousOverrideWarning,
useSubmissionStatusPolling,
useUploadCategoryOptions,
} from "../uploadHooks";
@@ -16,6 +18,20 @@ const { mockAbort, mockStart } = vi.hoisted(() => ({
mockAbort: vi.fn(),
}));
+const overrideMocks = vi.hoisted(() => ({
+ readEntry: vi.fn(),
+ find: vi.fn(),
+}));
+
+vi.mock("../readZipFilenames", async (importOriginal) => ({
+ ...(await importOriginal()),
+ readZipEntryText: overrideMocks.readEntry,
+}));
+
+vi.mock("../../p/readmeEdit/overrideMigration", () => ({
+ findPreviousReadmeOverride: overrideMocks.find,
+}));
+
vi.mock("@thunderstore/ts-uploader", () => {
class MockMultipartUpload {
handle = { uuid: "upload-uuid-1" };
@@ -403,3 +419,64 @@ describe("useUploadCategoryOptions", () => {
unmount();
});
});
+
+describe("usePreviousOverrideWarning", () => {
+ const requestConfig = () => ({ apiHost: "https://api.example.com" });
+ const firstOverride: PreviousOverride = {
+ versionNumber: "1.0.1",
+ markdown: "First README",
+ };
+ const secondOverride: PreviousOverride = {
+ versionNumber: "1.0.2",
+ markdown: "Second README",
+ };
+ let latest: PreviousOverride | null | undefined;
+ let finishFirst: (value: PreviousOverride) => void;
+
+ function Harness({ file }: { file: File }) {
+ latest = usePreviousOverrideWarning(requestConfig, file, "TeamA");
+ return null;
+ }
+
+ const settle = () =>
+ act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ beforeEach(() => {
+ latest = undefined;
+ overrideMocks.readEntry.mockImplementation((file: File) =>
+ Promise.resolve(
+ JSON.stringify({ name: file.name === "first.zip" ? "First" : "Second" })
+ )
+ );
+ overrideMocks.find.mockImplementation(
+ (_config: unknown, _team: string, name: string) =>
+ name === "First"
+ ? new Promise((resolve) => {
+ finishFirst = resolve;
+ })
+ : Promise.resolve(secondOverride)
+ );
+ });
+
+ it("ignores a lookup that finishes after the file changed", async () => {
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ await act(async () => {
+ root.render(React.createElement(Harness, { file: zipFile("first.zip") }));
+ });
+ await settle();
+ await act(async () => {
+ root.render(
+ React.createElement(Harness, { file: zipFile("second.zip") })
+ );
+ });
+ await settle();
+ await act(async () => {
+ finishFirst(firstOverride);
+ });
+ expect(latest).toEqual(secondOverride);
+ act(() => root.unmount());
+ });
+});
diff --git a/apps/cyberstorm-remix/app/upload/__tests__/uploadZipValidation.test.ts b/apps/cyberstorm-remix/app/upload/__tests__/uploadZipValidation.test.ts
index 8ea390c31..952b8c11a 100644
--- a/apps/cyberstorm-remix/app/upload/__tests__/uploadZipValidation.test.ts
+++ b/apps/cyberstorm-remix/app/upload/__tests__/uploadZipValidation.test.ts
@@ -5,79 +5,10 @@ import {
evaluateZipContents,
validatePackageZip,
} from "../uploadZipValidation";
+import { buildZip, zipFile } from "./zipFixtures";
const VALID_ROOT = ["manifest.json", "icon.png", "README.md"];
-/**
- * Builds a minimal, uncompressed (stored) ZIP containing the given entries
- * with empty contents. Only the structures `readZipFilenames` parses (local
- * headers, central directory, EOCD) are produced — enough to exercise the
- * filename reader without pulling in a ZIP library.
- */
-function buildZip(filenames: string[]): Uint8Array {
- const encoder = new TextEncoder();
- const localChunks: Uint8Array[] = [];
- const centralChunks: Uint8Array[] = [];
- const localOffsets: number[] = [];
- let offset = 0;
-
- for (const name of filenames) {
- const nameBytes = encoder.encode(name);
- localOffsets.push(offset);
-
- const local = new Uint8Array(30 + nameBytes.length);
- const view = new DataView(local.buffer);
- view.setUint32(0, 0x04034b50, true);
- view.setUint16(4, 20, true);
- view.setUint16(6, 0x0800, true);
- view.setUint16(26, nameBytes.length, true);
- local.set(nameBytes, 30);
-
- localChunks.push(local);
- offset += local.length;
- }
-
- const centralStart = offset;
- filenames.forEach((name, index) => {
- const nameBytes = encoder.encode(name);
- const central = new Uint8Array(46 + nameBytes.length);
- const view = new DataView(central.buffer);
- view.setUint32(0, 0x02014b50, true);
- view.setUint16(4, 20, true);
- view.setUint16(6, 20, true);
- view.setUint16(8, 0x0800, true);
- view.setUint16(28, nameBytes.length, true);
- view.setUint32(42, localOffsets[index], true);
- central.set(nameBytes, 46);
-
- centralChunks.push(central);
- offset += central.length;
- });
- const centralSize = offset - centralStart;
-
- const eocd = new Uint8Array(22);
- const eocdView = new DataView(eocd.buffer);
- eocdView.setUint32(0, 0x06054b50, true);
- eocdView.setUint16(8, filenames.length, true);
- eocdView.setUint16(10, filenames.length, true);
- eocdView.setUint32(12, centralSize, true);
- eocdView.setUint32(16, centralStart, true);
-
- const chunks = [...localChunks, ...centralChunks, eocd];
- const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
- const out = new Uint8Array(total);
- let cursor = 0;
- for (const chunk of chunks) {
- out.set(chunk, cursor);
- cursor += chunk.length;
- }
- return out;
-}
-
-function zipFile(filenames: string[], name = "package.zip"): File {
- return new File([buildZip(filenames)], name, { type: "application/zip" });
-}
-
describe("evaluateZipContents", () => {
it("accepts a well-formed package with no warnings or errors", () => {
const result = evaluateZipContents("package.zip", VALID_ROOT);
diff --git a/apps/cyberstorm-remix/app/upload/__tests__/zipFixtures.ts b/apps/cyberstorm-remix/app/upload/__tests__/zipFixtures.ts
new file mode 100644
index 000000000..64b171e31
--- /dev/null
+++ b/apps/cyberstorm-remix/app/upload/__tests__/zipFixtures.ts
@@ -0,0 +1,125 @@
+/** Builds ZIP fixtures with independently adjustable headers and payloads. */
+
+export interface ZipEntryContent {
+ /** Compression method as written to the headers: 0 stored, 8 deflate. */
+ method: number;
+ /** Bytes written after the local header, already compressed for method 8. */
+ data: Uint8Array;
+ uncompressedSize: number;
+ crc32?: number;
+}
+
+export function buildZip(
+ filenames: string[],
+ contents: Record = {}
+): Uint8Array {
+ const encoder = new TextEncoder();
+ const localChunks: Uint8Array[] = [];
+ const centralChunks: Uint8Array[] = [];
+ const localOffsets: number[] = [];
+ let offset = 0;
+
+ for (const name of filenames) {
+ const nameBytes = encoder.encode(name);
+ const content = contents[name];
+ localOffsets.push(offset);
+
+ const local = new Uint8Array(
+ 30 + nameBytes.length + (content?.data.length ?? 0)
+ );
+ const view = new DataView(local.buffer);
+ view.setUint32(0, 0x04034b50, true);
+ view.setUint16(4, 20, true);
+ view.setUint16(6, 0x0800, true);
+ view.setUint16(8, content?.method ?? 0, true);
+ view.setUint32(14, content?.crc32 ?? 0, true);
+ view.setUint32(18, content?.data.length ?? 0, true);
+ view.setUint32(22, content?.uncompressedSize ?? 0, true);
+ view.setUint16(26, nameBytes.length, true);
+ local.set(nameBytes, 30);
+ if (content) local.set(content.data, 30 + nameBytes.length);
+
+ localChunks.push(local);
+ offset += local.length;
+ }
+
+ const centralStart = offset;
+ filenames.forEach((name, index) => {
+ const nameBytes = encoder.encode(name);
+ const content = contents[name];
+ const central = new Uint8Array(46 + nameBytes.length);
+ const view = new DataView(central.buffer);
+ view.setUint32(0, 0x02014b50, true);
+ view.setUint16(4, 20, true);
+ view.setUint16(6, 20, true);
+ view.setUint16(8, 0x0800, true);
+ view.setUint16(10, content?.method ?? 0, true);
+ view.setUint32(16, content?.crc32 ?? 0, true);
+ view.setUint32(20, content?.data.length ?? 0, true);
+ view.setUint32(24, content?.uncompressedSize ?? 0, true);
+ view.setUint16(28, nameBytes.length, true);
+ view.setUint32(42, localOffsets[index], true);
+ central.set(nameBytes, 46);
+
+ centralChunks.push(central);
+ offset += central.length;
+ });
+ const centralSize = offset - centralStart;
+
+ const eocd = new Uint8Array(22);
+ const eocdView = new DataView(eocd.buffer);
+ eocdView.setUint32(0, 0x06054b50, true);
+ eocdView.setUint16(8, filenames.length, true);
+ eocdView.setUint16(10, filenames.length, true);
+ eocdView.setUint32(12, centralSize, true);
+ eocdView.setUint32(16, centralStart, true);
+
+ const chunks = [...localChunks, ...centralChunks, eocd];
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
+ const out = new Uint8Array(total);
+ let cursor = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, cursor);
+ cursor += chunk.length;
+ }
+ return out;
+}
+
+export function zipFile(
+ filenames: string[],
+ name = "package.zip",
+ contents?: Record
+): File {
+ return new File([buildZip(filenames, contents)], name, {
+ type: "application/zip",
+ });
+}
+
+function crc32(data: Uint8Array): number {
+ let crc = 0xffffffff;
+ for (const byte of data) {
+ crc ^= byte;
+ for (let bit = 0; bit < 8; bit++) {
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
+ }
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+export function storedEntry(text: string): ZipEntryContent {
+ const data = new TextEncoder().encode(text);
+ return { method: 0, data, uncompressedSize: data.length, crc32: crc32(data) };
+}
+
+export async function deflatedEntry(text: string): Promise {
+ const raw = new TextEncoder().encode(text);
+ const compressed = await new Response(
+ new Blob([raw]).stream().pipeThrough(new CompressionStream("deflate-raw"))
+ ).arrayBuffer();
+ return {
+ method: 8,
+ data: new Uint8Array(compressed),
+ uncompressedSize: raw.length,
+ crc32: crc32(raw),
+ };
+}
diff --git a/apps/cyberstorm-remix/app/upload/components/OverrideMigrationNotice.tsx b/apps/cyberstorm-remix/app/upload/components/OverrideMigrationNotice.tsx
new file mode 100644
index 000000000..04cb79560
--- /dev/null
+++ b/apps/cyberstorm-remix/app/upload/components/OverrideMigrationNotice.tsx
@@ -0,0 +1,136 @@
+import { useEffect, useRef, useState } from "react";
+import { useOutletContext } from "react-router";
+
+import { NewAlert, NewButton, useToast } from "@thunderstore/cyberstorm";
+import {
+ extractApiErrorMessage,
+ isApiError,
+ postPackageVersionReadme,
+} from "@thunderstore/thunderstore-api";
+
+import {
+ type PreviousOverride,
+ downloadOverrideText,
+ findPreviousReadmeOverride,
+} from "../../p/readmeEdit/overrideMigration";
+import type { OutletContextShape } from "../../root";
+
+export interface OverrideMigrationNoticeProps {
+ namespace: string;
+ packageName: string;
+ newVersion: string;
+ /** The override the upload form offered and the submitter opted to carry.
+ It is copied on mount, and the manual offer remains as the fallback if
+ that fails. */
+ overrideToCarry?: PreviousOverride | null;
+}
+
+/**
+ * Post-upload notice: when the previous version carried a site-edited README,
+ * the new upload starts clean, so tell the submitter and offer to carry the
+ * edit over or download it.
+ */
+export function OverrideMigrationNotice({
+ namespace,
+ packageName,
+ newVersion,
+ overrideToCarry,
+}: OverrideMigrationNoticeProps) {
+ const outletContext = useOutletContext() as OutletContextShape;
+ const toast = useToast();
+
+ const [previousOverride, setPreviousOverride] = useState(
+ overrideToCarry ?? null
+ );
+ const [copying, setCopying] = useState(false);
+ const [copied, setCopied] = useState(false);
+ const carryAttempted = useRef(false);
+
+ useEffect(() => {
+ if (overrideToCarry) return;
+ let cancelled = false;
+ findPreviousReadmeOverride(
+ outletContext.requestConfig,
+ namespace,
+ packageName,
+ newVersion
+ )
+ .then((result) => {
+ if (!cancelled) setPreviousOverride(result);
+ })
+ .catch(() => {
+ // The notice is best-effort. A probe failure must not break the
+ // submission result page.
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [namespace, packageName, newVersion, overrideToCarry]);
+
+ async function copyReadme(override: PreviousOverride) {
+ setCopying(true);
+ try {
+ await postPackageVersionReadme({
+ config: outletContext.requestConfig,
+ params: { namespace, package: packageName, version: newVersion },
+ data: { readme: override.markdown },
+ queryParams: {},
+ });
+ toast.addToast({
+ csVariant: "success",
+ children:
+ "Site-edited README carried over. Changes might take several minutes to show publicly!",
+ duration: 8000,
+ });
+ setCopied(true);
+ } catch (error) {
+ toast.addToast({
+ csVariant: "danger",
+ children: `Carrying the edit over failed: ${
+ isApiError(error) ? extractApiErrorMessage(error) : "unknown error"
+ }`,
+ duration: 8000,
+ });
+ } finally {
+ setCopying(false);
+ }
+ }
+
+ useEffect(() => {
+ if (!overrideToCarry || carryAttempted.current) return;
+ carryAttempted.current = true;
+ copyReadme(overrideToCarry);
+ }, [overrideToCarry]);
+
+ if (!previousOverride || copied) return null;
+
+ return (
+
+
+
+ Version {newVersion} uses the README included in your package. Version{" "}
+ {previousOverride.versionNumber} still has its edited README. You can
+ copy that README to this version’s Thunderstore page.
+
+
+ copyReadme(previousOverride)}
+ disabled={copying}
+ >
+ {copying ? "Copying…" : "Copy edited README"}
+
+ downloadOverrideText(previousOverride.markdown)}
+ disabled={copying}
+ >
+ Download edited README
+
+
+
+
+ );
+}
diff --git a/apps/cyberstorm-remix/app/upload/components/SubmissionResult.css b/apps/cyberstorm-remix/app/upload/components/SubmissionResult.css
index 154a07228..7ddd662c8 100644
--- a/apps/cyberstorm-remix/app/upload/components/SubmissionResult.css
+++ b/apps/cyberstorm-remix/app/upload/components/SubmissionResult.css
@@ -4,4 +4,18 @@
flex-wrap: wrap;
gap: var(--gap-2xs);
}
+
+ .override-migration-notice {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-8);
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ }
+
+ .override-migration-notice__actions {
+ display: flex;
+ gap: var(--space-8);
+ }
}
diff --git a/apps/cyberstorm-remix/app/upload/components/SubmissionResult.tsx b/apps/cyberstorm-remix/app/upload/components/SubmissionResult.tsx
index 12229e103..45ad7b7f2 100644
--- a/apps/cyberstorm-remix/app/upload/components/SubmissionResult.tsx
+++ b/apps/cyberstorm-remix/app/upload/components/SubmissionResult.tsx
@@ -13,14 +13,18 @@ import { type PackageSubmissionResult } from "@thunderstore/dapper/types";
import { Island } from "../../commonComponents/Island/Island";
import { PageHeader } from "../../commonComponents/PageHeader/PageHeader";
+import { type PreviousOverride } from "../../p/readmeEdit/overrideMigration";
+import { OverrideMigrationNotice } from "./OverrideMigrationNotice";
import "./SubmissionResult.css";
export interface SubmissionResultProps {
submissionStatusResult: PackageSubmissionResult;
+ overrideToCarry: PreviousOverride | null;
}
export function SubmissionResult({
submissionStatusResult,
+ overrideToCarry,
}: SubmissionResultProps) {
return (
@@ -57,6 +61,13 @@ export function SubmissionResult({
{submissionStatusResult.package_version.name}
+
+
diff --git a/apps/cyberstorm-remix/app/upload/components/UploadFileSection.css b/apps/cyberstorm-remix/app/upload/components/UploadFileSection.css
index 5bbc4571e..41945acf4 100644
--- a/apps/cyberstorm-remix/app/upload/components/UploadFileSection.css
+++ b/apps/cyberstorm-remix/app/upload/components/UploadFileSection.css
@@ -90,4 +90,26 @@
.drag-n-drop:hover .drag-n-drop__remove-button {
color: var(--color-text-primary);
}
+
+ .upload-override-warning {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-8);
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ }
+
+ .upload-override-warning__actions {
+ display: inline-flex;
+ flex-wrap: wrap;
+ gap: var(--space-16);
+ align-items: center;
+ }
+
+ .upload-override-warning__carry {
+ display: inline-flex;
+ gap: var(--space-8);
+ align-items: center;
+ }
}
diff --git a/apps/cyberstorm-remix/app/upload/components/UploadFileSection.tsx b/apps/cyberstorm-remix/app/upload/components/UploadFileSection.tsx
index b641872bb..d175398a9 100644
--- a/apps/cyberstorm-remix/app/upload/components/UploadFileSection.tsx
+++ b/apps/cyberstorm-remix/app/upload/components/UploadFileSection.tsx
@@ -7,11 +7,21 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import type { RefObject } from "react";
-import { NewAlert, NewIcon, classnames } from "@thunderstore/cyberstorm";
+import {
+ NewAlert,
+ NewButton,
+ NewIcon,
+ NewSwitch,
+ classnames,
+} from "@thunderstore/cyberstorm";
import { DnDFileInput } from "@thunderstore/react-dnd";
import type { IBaseUploadHandle } from "@thunderstore/ts-uploader";
import { FormSection } from "../../commonComponents/FormSection/FormSection";
+import {
+ type PreviousOverride,
+ downloadOverrideText,
+} from "../../p/readmeEdit/overrideMigration";
import { PACKAGE_ZIP_ACCEPT, formatBytes } from "../uploadUtils";
import { SectionErrors } from "./SectionErrors";
import "./UploadFileSection.css";
@@ -23,9 +33,12 @@ export interface UploadFileSectionProps {
sectionErrors: string[];
fileWarnings: string[];
fileValidationErrors: string[];
+ previousOverride: PreviousOverride | null;
+ carryOverride: boolean;
fileInputRef: RefObject;
onFileChange: (file: File | null) => void;
onRemoveFile: () => void;
+ onCarryOverrideChange: (carryOverride: boolean) => void;
}
export function UploadFileSection({
@@ -35,9 +48,12 @@ export function UploadFileSection({
sectionErrors,
fileWarnings,
fileValidationErrors,
+ previousOverride,
+ carryOverride,
fileInputRef,
onFileChange,
onRemoveFile,
+ onCarryOverrideChange,
}: UploadFileSectionProps) {
return (
) : null}
+ {previousOverride ? (
+
+
+
+ Version {previousOverride.versionNumber} has a README edited on
+ Thunderstore. This upload uses the README in your package unless
+ you choose to copy that edit.
+
+
+
+
+
+
+ downloadOverrideText(previousOverride.markdown)}
+ >
+ Download edited README
+
+
+
+
+ ) : null}
diff --git a/apps/cyberstorm-remix/app/upload/components/UploadSubmissionStatus.tsx b/apps/cyberstorm-remix/app/upload/components/UploadSubmissionStatus.tsx
index 7d7d5ab7a..c0f150d54 100644
--- a/apps/cyberstorm-remix/app/upload/components/UploadSubmissionStatus.tsx
+++ b/apps/cyberstorm-remix/app/upload/components/UploadSubmissionStatus.tsx
@@ -2,6 +2,7 @@ import { NewAlert, NewButton } from "@thunderstore/cyberstorm";
import type { PackageSubmissionStatus } from "@thunderstore/dapper/types";
import { FormSectionSeparator } from "../../commonComponents/FormSection/FormSection";
+import { type PreviousOverride } from "../../p/readmeEdit/overrideMigration";
import { SectionErrors } from "./SectionErrors";
import { SubmissionProcessingSkeleton } from "./SubmissionProcessingSkeleton";
import { SubmissionResult } from "./SubmissionResult";
@@ -11,6 +12,7 @@ export interface UploadSubmissionStatusProps {
submissionStatus?: PackageSubmissionStatus;
pollingError: string | null;
submitSectionErrors: string[];
+ overrideToCarry: PreviousOverride | null;
onRetryPolling: () => void;
}
@@ -19,6 +21,7 @@ export function UploadSubmissionStatus({
submissionStatus,
pollingError,
submitSectionErrors,
+ overrideToCarry,
onRetryPolling,
}: UploadSubmissionStatusProps) {
const showProcessing =
@@ -31,7 +34,10 @@ export function UploadSubmissionStatus({
{submissionStatus?.result ? (
-
+
) : null}
{showProcessing ?
: null}
{!showProcessing && pollingError != null ? (
diff --git a/apps/cyberstorm-remix/app/upload/components/__tests__/OverrideMigrationNotice.test.ts b/apps/cyberstorm-remix/app/upload/components/__tests__/OverrideMigrationNotice.test.ts
new file mode 100644
index 000000000..1a0ad67fb
--- /dev/null
+++ b/apps/cyberstorm-remix/app/upload/components/__tests__/OverrideMigrationNotice.test.ts
@@ -0,0 +1,128 @@
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { Outlet, RouterProvider, createMemoryRouter } from "react-router";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { PreviousOverride } from "../../../p/readmeEdit/overrideMigration";
+import { OverrideMigrationNotice } from "../OverrideMigrationNotice";
+
+(
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }
+).IS_REACT_ACT_ENVIRONMENT = true;
+
+const mocks = vi.hoisted(() => ({
+ find: vi.fn(),
+ post: vi.fn(),
+ toast: vi.fn(),
+}));
+
+vi.mock("@thunderstore/cyberstorm", () => ({
+ NewAlert: ({ children }: { children: React.ReactNode }) => children,
+ NewButton: ({
+ children,
+ onClick,
+ disabled,
+ }: {
+ children: React.ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+ }) => React.createElement("button", { onClick, disabled }, children),
+ useToast: () => ({ addToast: mocks.toast }),
+}));
+vi.mock("@thunderstore/thunderstore-api", () => ({
+ extractApiErrorMessage: (error: Error) => error.message,
+ isApiError: (error: unknown) =>
+ !!error && typeof error === "object" && "response" in error,
+ postPackageVersionReadme: mocks.post,
+}));
+vi.mock("../../../p/readmeEdit/overrideMigration", () => ({
+ downloadOverrideText: vi.fn(),
+ findPreviousReadmeOverride: mocks.find,
+}));
+
+const override: PreviousOverride = {
+ versionNumber: "1.0.1",
+ markdown: "Edited README",
+};
+let cleanup: (() => void) | undefined;
+
+async function renderNotice(overrideToCarry: PreviousOverride | null) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const router = createMemoryRouter([
+ {
+ path: "/",
+ element: React.createElement(Outlet, {
+ context: { requestConfig: () => ({}) },
+ }),
+ children: [
+ {
+ index: true,
+ element: React.createElement(OverrideMigrationNotice, {
+ namespace: "Team",
+ packageName: "Mod",
+ newVersion: "2.0.0",
+ overrideToCarry,
+ }),
+ },
+ ],
+ },
+ ]);
+ await act(async () => {
+ root.render(
+ React.createElement(
+ React.StrictMode,
+ null,
+ React.createElement(RouterProvider, { router })
+ )
+ );
+ });
+ cleanup = () => {
+ act(() => root.unmount());
+ router.dispose();
+ container.remove();
+ };
+ return container;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.find.mockResolvedValue(override);
+ mocks.post.mockResolvedValue({});
+});
+
+afterEach(() => {
+ cleanup?.();
+ cleanup = undefined;
+});
+
+describe("OverrideMigrationNotice", () => {
+ it("copies the override chosen on the upload form exactly once", async () => {
+ await renderNotice(override);
+ expect(mocks.post).toHaveBeenCalledTimes(1);
+ expect(mocks.post).toHaveBeenCalledWith(
+ expect.objectContaining({
+ params: { namespace: "Team", package: "Mod", version: "2.0.0" },
+ data: { readme: "Edited README" },
+ })
+ );
+ expect(mocks.find).not.toHaveBeenCalled();
+ });
+
+ it("offers a manual copy when the automatic copy fails", async () => {
+ mocks.post.mockRejectedValue(new Error("offline"));
+ const container = await renderNotice(override);
+ expect(container.textContent).toContain("Copy edited README");
+ expect(mocks.toast).toHaveBeenCalledWith(
+ expect.objectContaining({ csVariant: "danger" })
+ );
+ });
+
+ it("only offers a copy when the submitter did not opt in", async () => {
+ const container = await renderNotice(null);
+ expect(mocks.find).toHaveBeenCalled();
+ expect(container.textContent).toContain("Copy edited README");
+ expect(mocks.post).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/cyberstorm-remix/app/upload/components/__tests__/UploadSubmissionStatus.test.ts b/apps/cyberstorm-remix/app/upload/components/__tests__/UploadSubmissionStatus.test.ts
index 5e069adc2..469c13cd1 100644
--- a/apps/cyberstorm-remix/app/upload/components/__tests__/UploadSubmissionStatus.test.ts
+++ b/apps/cyberstorm-remix/app/upload/components/__tests__/UploadSubmissionStatus.test.ts
@@ -95,6 +95,7 @@ describe("UploadSubmissionStatus", () => {
submissionStatus: asStatus("PENDING"),
pollingError: null,
submitSectionErrors: [],
+ overrideToCarry: null,
onRetryPolling: vi.fn(),
})
);
@@ -113,6 +114,7 @@ describe("UploadSubmissionStatus", () => {
submitting: true,
pollingError: null,
submitSectionErrors: [],
+ overrideToCarry: null,
onRetryPolling: vi.fn(),
})
);
@@ -132,6 +134,7 @@ describe("UploadSubmissionStatus", () => {
submissionStatus: asStatus("FINISHED", true),
pollingError: null,
submitSectionErrors: ["Submit failed"],
+ overrideToCarry: null,
onRetryPolling: vi.fn(),
})
);
@@ -152,6 +155,7 @@ describe("UploadSubmissionStatus", () => {
submissionStatus: asStatus("PENDING"),
pollingError: "network error",
submitSectionErrors: [],
+ overrideToCarry: null,
onRetryPolling: vi.fn(),
})
);
@@ -172,6 +176,7 @@ describe("UploadSubmissionStatus", () => {
submissionStatus: asStatus("FINISHED"),
pollingError: "network error",
submitSectionErrors: [],
+ overrideToCarry: null,
onRetryPolling: vi.fn(),
})
);
diff --git a/apps/cyberstorm-remix/app/upload/readZipFilenames.ts b/apps/cyberstorm-remix/app/upload/readZipFilenames.ts
index 1d5b7a556..6a050572a 100644
--- a/apps/cyberstorm-remix/app/upload/readZipFilenames.ts
+++ b/apps/cyberstorm-remix/app/upload/readZipFilenames.ts
@@ -1,206 +1,78 @@
-/**
- * Reads the list of entry filenames from a ZIP file by parsing its central
- * directory, without decompressing any contents.
- *
- * The pre-upload package validation (see {@link ./uploadZipValidation}) only
- * needs the entry names — never the file data — so this deliberately avoids a
- * full ZIP library. It reads just the tail of the file (the End Of Central
- * Directory record and the central directory itself) via `Blob.slice`, so it
- * stays cheap even for the 10GB maximum upload size.
- *
- * Returns `null` when the archive can't be parsed (corrupt, unsupported, or
- * not actually a ZIP). Callers should treat `null` as "couldn't analyse" and
- * fall back to server-side validation rather than blocking the upload.
- */
-
-const EOCD_SIGNATURE = 0x06054b50;
-const EOCD_MIN_SIZE = 22;
-const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50;
-const ZIP64_EOCD_LOCATOR_SIZE = 20;
-const ZIP64_EOCD_SIGNATURE = 0x06064b50;
-const ZIP64_EOCD_SIZE = 56;
-const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50;
-const CENTRAL_DIRECTORY_HEADER_MIN_SIZE = 46;
+import {
+ BlobReader,
+ TextWriter,
+ ZipReader,
+} from "@zip.js/zip.js/lib/zip-core-native.js";
-/** Max ZIP comment length is 0xffff; the EOCD lives within this of the end. */
-const MAX_EOCD_SEARCH = EOCD_MIN_SIZE + 0xffff;
-/** Guard against pathological archives: cap the central directory we read. */
-const MAX_CENTRAL_DIRECTORY_SIZE = 64 * 1024 * 1024;
-/** Guard against runaway loops on malformed central directories. */
+const MAX_READ_SIZE = 64 * 1024 * 1024;
const MAX_ENTRIES = 200_000;
+const MAX_ENTRY_SIZE = 1024 * 1024;
-async function sliceToDataView(
- file: File,
- start: number,
- end?: number
-): Promise
{
- const buffer = await file.slice(start, end).arrayBuffer();
- return new DataView(buffer);
-}
-
-/** Reads an unsigned 64-bit little-endian int, or null if it exceeds 2^53-1. */
-function readSafeUint64(view: DataView, offset: number): number | null {
- const low = view.getUint32(offset, true);
- const high = view.getUint32(offset + 4, true);
- const value = high * 0x1_0000_0000 + low;
- return Number.isSafeInteger(value) ? value : null;
-}
-
-interface CentralDirectoryLocation {
- offset: number;
- size: number;
-}
-
-function locateCentralDirectory(
- eocd: DataView,
- eocdOffset: number
-): CentralDirectoryLocation | "needs-zip64" | null {
- const size = eocd.getUint32(eocdOffset + 12, true);
- const offset = eocd.getUint32(eocdOffset + 16, true);
-
- // Only a maxed-out directory size/offset forces ZIP64. The record counts
- // (offsets +8/+10) legitimately read 0xffff for exactly 65535 entries, and
- // we never rely on them (the directory is walked by signature), so a 0xffff
- // count must NOT trigger the ZIP64 path — doing so made a valid 65535-entry
- // archive unreadable.
- if (size === 0xffffffff || offset === 0xffffffff) {
- return "needs-zip64";
- }
-
- return { offset, size };
-}
-
-async function locateZip64CentralDirectory(
- file: File,
- eocdAbsoluteOffset: number
-): Promise {
- const locatorOffset = eocdAbsoluteOffset - ZIP64_EOCD_LOCATOR_SIZE;
- if (locatorOffset < 0) return null;
-
- const locator = await sliceToDataView(
- file,
- locatorOffset,
- locatorOffset + ZIP64_EOCD_LOCATOR_SIZE
- );
- if (locator.getUint32(0, true) !== ZIP64_EOCD_LOCATOR_SIGNATURE) return null;
-
- const zip64EocdOffset = readSafeUint64(locator, 8);
- if (
- zip64EocdOffset === null ||
- zip64EocdOffset + ZIP64_EOCD_SIZE > file.size
- ) {
- return null;
- }
-
- const zip64Eocd = await sliceToDataView(
- file,
- zip64EocdOffset,
- zip64EocdOffset + ZIP64_EOCD_SIZE
- );
- if (zip64Eocd.getUint32(0, true) !== ZIP64_EOCD_SIGNATURE) return null;
-
- const size = readSafeUint64(zip64Eocd, 40);
- const offset = readSafeUint64(zip64Eocd, 48);
- if (size === null || offset === null) return null;
-
- return { offset, size };
-}
+// Inspect metadata without buffering multi-gigabyte uploads or unbounded directories.
+class MetadataReader extends BlobReader {
+ private bytesRead = 0;
-function findEocdOffset(eocd: DataView): number | null {
- // Scan backwards: the EOCD is the last record, optionally followed only by
- // its own variable-length comment.
- for (let i = eocd.byteLength - EOCD_MIN_SIZE; i >= 0; i--) {
- if (eocd.getUint32(i, true) !== EOCD_SIGNATURE) continue;
- const commentLength = eocd.getUint16(i + 20, true);
- // Accept the record as long as its declared comment fits within the slice.
- // Requiring it to end exactly at EOF rejected otherwise-valid archives that
- // carry trailing bytes after the EOCD (e.g. appended signatures/metadata).
- if (i + EOCD_MIN_SIZE + commentLength <= eocd.byteLength) {
- return i;
- }
- }
- return null;
-}
-
-function parseCentralDirectoryNames(
- centralDirectory: DataView
-): string[] | null {
- const decoder = new TextDecoder("utf-8");
- const names: string[] = [];
- let cursor = 0;
-
- while (
- cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE <=
- centralDirectory.byteLength
- ) {
+ override async readUint8Array(offset: number, length: number) {
+ this.bytesRead += length;
if (
- centralDirectory.getUint32(cursor, true) !==
- CENTRAL_DIRECTORY_HEADER_SIGNATURE
+ !Number.isSafeInteger(length) ||
+ length < 0 ||
+ this.bytesRead > MAX_READ_SIZE
) {
- // The central directory is contiguous fixed-signature records; a record
- // that doesn't start with the signature means it's truncated or corrupt.
- // Return null (defer to the server) rather than a partial, misleading
- // list that would raise false "missing manifest/icon/README" errors.
- return null;
+ throw new Error("ZIP inspection exceeds the read limit");
}
-
- const nameLength = centralDirectory.getUint16(cursor + 28, true);
- const extraLength = centralDirectory.getUint16(cursor + 30, true);
- const commentLength = centralDirectory.getUint16(cursor + 32, true);
- const nameStart = cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE;
- const nameEnd = nameStart + nameLength;
- if (nameEnd > centralDirectory.byteLength) return null;
- // The record also declares extra and comment fields after the name. A final
- // record whose declared length runs past the directory is truncated, so
- // defer to the server rather than returning a partial, misleading list.
- const nextCursor = nameEnd + extraLength + commentLength;
- if (nextCursor > centralDirectory.byteLength) return null;
-
- const nameBytes = new Uint8Array(
- centralDirectory.buffer,
- centralDirectory.byteOffset + nameStart,
- nameLength
- );
- names.push(decoder.decode(nameBytes));
-
- cursor = nextCursor;
- // An archive with more entries than we're willing to scan: defer to the
- // server instead of validating against a truncated list.
- if (names.length > MAX_ENTRIES) return null;
+ return super.readUint8Array(offset, length);
}
-
- // A non-empty central directory that yields no parseable headers is corrupt.
- return names.length > 0 ? names : null;
}
+/** Returns null when inspection fails so server-side validation can take over. */
export async function readZipFilenames(file: File): Promise {
+ const reader = new ZipReader(new MetadataReader(file));
try {
- const fileSize = file.size;
- if (fileSize < EOCD_MIN_SIZE) return null;
-
- const searchSize = Math.min(fileSize, MAX_EOCD_SEARCH);
- const searchStart = fileSize - searchSize;
- const eocd = await sliceToDataView(file, searchStart);
-
- const eocdOffset = findEocdOffset(eocd);
- if (eocdOffset === null) return null;
- const eocdAbsoluteOffset = searchStart + eocdOffset;
-
- let location = locateCentralDirectory(eocd, eocdOffset);
- if (location === "needs-zip64") {
- location = await locateZip64CentralDirectory(file, eocdAbsoluteOffset);
+ const names: string[] = [];
+ for await (const entry of reader.getEntriesGenerator()) {
+ if (names.length === MAX_ENTRIES) return null;
+ names.push(entry.filename);
}
- if (location === null) return null;
-
- const { offset, size } = location;
- // An empty but well-formed archive has a zero-length central directory.
- if (size === 0) return [];
- if (size < 0 || size > MAX_CENTRAL_DIRECTORY_SIZE) return null;
- if (offset < 0 || offset + size > fileSize) return null;
+ return names;
+ } catch {
+ return null;
+ } finally {
+ await reader.close();
+ }
+}
- const centralDirectory = await sliceToDataView(file, offset, offset + size);
- return parseCentralDirectoryNames(centralDirectory);
+/**
+ * Extracts only small metadata files. zip.js rejects output that exceeds the
+ * declared size, so the declared-size limit also bounds the decoded bytes.
+ */
+export async function readZipEntryText(
+ file: File,
+ entryName: string
+): Promise {
+ const reader = new ZipReader(new MetadataReader(file), {
+ useWebWorkers: false,
+ });
+ try {
+ let count = 0;
+ for await (const entry of reader.getEntriesGenerator()) {
+ if (++count > MAX_ENTRIES) return null;
+ if (entry.filename !== entryName) continue;
+ if (
+ entry.directory ||
+ entry.encrypted ||
+ entry.compressedSize > MAX_ENTRY_SIZE ||
+ entry.uncompressedSize > MAX_ENTRY_SIZE
+ ) {
+ return null;
+ }
+
+ return await entry.getData(new TextWriter(), { checkSignature: true });
+ }
+ return null;
} catch {
return null;
+ } finally {
+ await reader.close();
}
}
diff --git a/apps/cyberstorm-remix/app/upload/upload.tsx b/apps/cyberstorm-remix/app/upload/upload.tsx
index e644bcac6..e44c68cf8 100644
--- a/apps/cyberstorm-remix/app/upload/upload.tsx
+++ b/apps/cyberstorm-remix/app/upload/upload.tsx
@@ -23,6 +23,7 @@ import {
} from "../commonComponents/FormSection/FormSection";
import { Page } from "../commonComponents/Page/Page";
import { PageHeader } from "../commonComponents/PageHeader/PageHeader";
+import { type PreviousOverride } from "../p/readmeEdit/overrideMigration";
import { type OutletContextShape } from "../root";
import type { Route } from "./+types/upload";
import "./Upload.css";
@@ -35,6 +36,7 @@ import { UploadSubmitSection } from "./components/UploadSubmitSection";
import { UploadTeamSection } from "./components/UploadTeamSection";
import {
usePackageFileUpload,
+ usePreviousOverrideWarning,
useSubmissionStatusPolling,
useUploadCategoryOptions,
} from "./uploadHooks";
@@ -170,6 +172,17 @@ export default function Upload() {
formInputs.communities
);
+ const previousOverride = usePreviousOverrideWarning(
+ requestConfig,
+ file,
+ formInputs.author_name
+ );
+ const [carryOverride, setCarryOverride] = useState(false);
+ // Fixed at submit, so the result copies the override the form showed and
+ // later switch changes cannot write to the published version.
+ const [overrideToCarry, setOverrideToCarry] =
+ useState(null);
+
type SubmitorOutput = Awaited<
ReturnType
>;
@@ -254,12 +267,15 @@ export default function Upload() {
const handleReset = () => {
clearFile();
+ setCarryOverride(false);
+ setOverrideToCarry(null);
setSubmitError(null);
setSubmissionStatus(undefined);
dispatchForm("reset");
};
const handleSubmit = () => {
+ setOverrideToCarry(carryOverride ? previousOverride : null);
setSubmitError(null);
setPollingError(null);
setSubmissionStatus(undefined);
@@ -316,9 +332,12 @@ export default function Upload() {
sectionErrors={submissionErrorsBySection.uploadFile}
fileWarnings={fileWarnings}
fileValidationErrors={fileErrors}
+ previousOverride={previousOverride}
+ carryOverride={carryOverride}
fileInputRef={fileInputRef}
onFileChange={(nextFile) => {
selectFile(nextFile);
+ setCarryOverride(false);
updateFormFieldState({
field: "upload_uuid",
value: "",
@@ -326,17 +345,20 @@ export default function Upload() {
}}
onRemoveFile={() => {
clearFile();
+ setCarryOverride(false);
updateFormFieldState({
field: "upload_uuid",
value: "",
});
}}
+ onCarryOverrideChange={setCarryOverride}
/>
{
+ setCarryOverride(false);
updateFormFieldState({
field: "author_name",
value: authorName,
@@ -404,6 +426,7 @@ export default function Upload() {
submissionStatus={submissionStatus}
pollingError={pollingError}
submitSectionErrors={submissionErrorsBySection.submit}
+ overrideToCarry={overrideToCarry}
onRetryPolling={retryPolling}
/>
) : null}
diff --git a/apps/cyberstorm-remix/app/upload/uploadHooks.ts b/apps/cyberstorm-remix/app/upload/uploadHooks.ts
index bd2924735..5d99e025b 100644
--- a/apps/cyberstorm-remix/app/upload/uploadHooks.ts
+++ b/apps/cyberstorm-remix/app/upload/uploadHooks.ts
@@ -7,7 +7,12 @@ import {
type UserMedia,
} from "@thunderstore/ts-uploader";
+import {
+ type PreviousOverride,
+ findPreviousReadmeOverride,
+} from "../p/readmeEdit/overrideMigration";
import type { OutletContextShape } from "../root";
+import { readZipEntryText } from "./readZipFilenames";
import {
type CategoryOption,
PACKAGE_ZIP_FILE_ERROR_MESSAGE,
@@ -296,3 +301,52 @@ export function useUploadCategoryOptions(
return categoryOptions;
}
+
+async function readPackageManifestName(file: File): Promise {
+ const manifest = await readZipEntryText(file, "manifest.json");
+ if (manifest === null) return null;
+ try {
+ const name: unknown = JSON.parse(manifest)?.name;
+ return typeof name === "string" && name !== "" ? name : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Looks up whether the package this file will become a new version of carries
+ * a site-edited README, so the form can warn before the upload rather than
+ * only after it. Advisory: any failure simply yields no warning.
+ */
+export function usePreviousOverrideWarning(
+ requestConfig: OutletContextShape["requestConfig"],
+ file: File | null,
+ authorName: string
+): PreviousOverride | null {
+ const [previousOverride, setPreviousOverride] =
+ useState(null);
+
+ useEffect(() => {
+ setPreviousOverride(null);
+ if (!file || !authorName) return;
+
+ let cancelled = false;
+ readPackageManifestName(file)
+ .then((name) =>
+ name
+ ? findPreviousReadmeOverride(requestConfig, authorName, name)
+ : null
+ )
+ .then((result) => {
+ if (!cancelled) setPreviousOverride(result);
+ })
+ .catch(() => {
+ // Advisory only: a failed probe must not disturb the form.
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [file, authorName]);
+
+ return previousOverride;
+}
diff --git a/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx b/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx
index 6858e4331..745f74770 100644
--- a/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx
+++ b/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx
@@ -192,6 +192,13 @@ const library: LinkLibrary = {
ref={p.customRef}
/>
),
+ PackageVersionReadmeEdit: (p) => (
+
+ ),
PackageVersionWithoutCommunity: (p) => (
RE | null;
/** PackageVersion's versions view */
PackageVersionVersions: (props: AnyProps & PackageVersionProps) => RE | null;
+ /** PackageVersion's readme/changelog editor */
+ PackageVersionReadmeEdit: (
+ props: AnyProps & PackageVersionProps
+ ) => RE | null;
/** PackageVersionWithoutCommunity's detail view */
PackageVersionWithoutCommunity: (
props: AnyProps & Omit
@@ -175,6 +179,7 @@ const library: LinkLibrary = {
PackageVersion: noop,
PackageVersionRequired: noop,
PackageVersionVersions: noop,
+ PackageVersionReadmeEdit: noop,
PackageVersionWithoutCommunity: noop,
PackageVersionWithoutCommunityRequired: noop,
PackageVersionWithoutCommunityVersions: noop,
diff --git a/packages/cyberstorm/src/components/Links/Links.tsx b/packages/cyberstorm/src/components/Links/Links.tsx
index c6b34a380..2349b00e9 100644
--- a/packages/cyberstorm/src/components/Links/Links.tsx
+++ b/packages/cyberstorm/src/components/Links/Links.tsx
@@ -41,6 +41,7 @@ export type CyberstormLinkIds =
| "PackageVersion"
| "PackageVersionRequired"
| "PackageVersionVersions"
+ | "PackageVersionReadmeEdit"
| "PackageVersionWithoutCommunity"
| "PackageVersionWithoutCommunityRequired"
| "PackageVersionWithoutCommunityVersions"
diff --git a/packages/cyberstorm/src/index.ts b/packages/cyberstorm/src/index.ts
index 3912b8054..62f2c2efc 100644
--- a/packages/cyberstorm/src/index.ts
+++ b/packages/cyberstorm/src/index.ts
@@ -73,6 +73,7 @@ export { CardPackage } from "./newComponents/Card/CardPackage/CardPackage";
export {
CodeInput,
type CodeInputProps,
+ ValidationBar as NewValidationBar,
} from "./newComponents/CodeInput/CodeInput";
export {
Container,
diff --git a/packages/cyberstorm/src/newComponents/CodeInput/CodeInput.tsx b/packages/cyberstorm/src/newComponents/CodeInput/CodeInput.tsx
index 6015c64e3..28e83cdf1 100644
--- a/packages/cyberstorm/src/newComponents/CodeInput/CodeInput.tsx
+++ b/packages/cyberstorm/src/newComponents/CodeInput/CodeInput.tsx
@@ -90,46 +90,62 @@ export const CodeInput = React.forwardRef(
CodeInput.displayName = "CodeInput";
-function ValidationBar(props: {
+export function ValidationBar(props: {
status: "waiting" | "processing" | "success" | "failure";
message?: string;
+ rootClasses?: string;
+ children?: ReactNode;
}): ReactNode {
if (props.status === "waiting") {
return (
-
+
{props.message ? props.message : "Waiting for input"}
+ {props.children}
);
} else if (props.status === "processing") {
return (
-
-
+
+
{props.message ? props.message : "Processing..."}
+ {props.children}
);
} else if (props.status === "success") {
return (
-
+
{props.message ? props.message : "All systems go!"}
+ {props.children}
);
} else {
return (
-
+
{props.message
? props.message
: "Problem, alarm, danger. Everything is going to explode."}
+ {props.children}
);
}
diff --git a/packages/dapper-fake/src/fakers/markup.ts b/packages/dapper-fake/src/fakers/markup.ts
index b947b1ad9..2635c24c4 100644
--- a/packages/dapper-fake/src/fakers/markup.ts
+++ b/packages/dapper-fake/src/fakers/markup.ts
@@ -1,4 +1,6 @@
export const getFakeChangelog = async () => ({
+ is_edited: false,
+ edited_at: null,
html: `
v1.2.3
@@ -33,6 +35,8 @@ export const getFakeChangelog = async () => ({
});
export const getFakeReadme = async () => ({
+ is_edited: false,
+ edited_at: null,
html: `
Markdown syntax guide
This is a Heading h2
diff --git a/packages/dapper-fake/src/fakers/package.ts b/packages/dapper-fake/src/fakers/package.ts
index 13db7c300..3949fdcfb 100644
--- a/packages/dapper-fake/src/fakers/package.ts
+++ b/packages/dapper-fake/src/fakers/package.ts
@@ -281,6 +281,7 @@ export const getFakePackageVersions = async (
download_count: faker.number.int({ min: 0, max: 200000 }),
download_url: `https://thunderstore.io/package/download/${namespace}/${name}/${versionNumber}/`,
install_url: `ror2mm://v1/install/thunderstore.io/${namespace}/${name}/${versionNumber}/`,
+ is_readme_edited: false,
};
});
};
diff --git a/packages/dapper/src/types/package.ts b/packages/dapper/src/types/package.ts
index 63520cb8f..634ca1a40 100644
--- a/packages/dapper/src/types/package.ts
+++ b/packages/dapper/src/types/package.ts
@@ -83,6 +83,7 @@ export interface PackageVersion {
download_count: number;
download_url: string;
install_url: string;
+ is_readme_edited?: boolean;
}
export interface PackageVersionDependency {
diff --git a/packages/dapper/src/types/shared.ts b/packages/dapper/src/types/shared.ts
index 1eb54d901..fb9a66dc2 100644
--- a/packages/dapper/src/types/shared.ts
+++ b/packages/dapper/src/types/shared.ts
@@ -5,10 +5,14 @@ export type DynamicLink = {
export type MarkdownResponse = {
html: string;
+ is_edited?: boolean;
+ edited_at?: string | null;
};
export type HTMLContentResponse = {
html: string;
+ is_edited?: boolean;
+ edited_at?: string | null;
};
export interface PackageCategory {
diff --git a/packages/thunderstore-api/src/get/packageVersionChangelogMarkdownRaw.ts b/packages/thunderstore-api/src/get/packageVersionChangelogMarkdownRaw.ts
new file mode 100644
index 000000000..dab782edd
--- /dev/null
+++ b/packages/thunderstore-api/src/get/packageVersionChangelogMarkdownRaw.ts
@@ -0,0 +1,50 @@
+import { apiFetch } from "../apiFetch";
+import { ApiError } from "../index";
+import type { ApiEndpointProps } from "../index";
+import type { PackageVersionMarkdownRequestParams } from "../schemas/requestSchemas";
+import { packageVersionRawMarkdownResponseDataSchema } from "../schemas/responseSchemas";
+import type { PackageVersionRawMarkdownResponseData } from "../schemas/responseSchemas";
+
+/**
+ * Fetches the resolved raw CHANGELOG of a version from the experimental API.
+ * Serves the override when one exists, the packaged file otherwise. Served
+ * from a five-minute server cache that edits do not bust.
+ */
+export function fetchPackageVersionChangelogMarkdownRaw(
+ props: ApiEndpointProps
+): Promise {
+ const { config, params } = props;
+ const path = `/api/experimental/package/${params.namespace}/${params.package}/${params.version}/changelog/`;
+
+ return apiFetch({
+ args: {
+ config,
+ path,
+ request: { cache: "no-store" },
+ },
+ requestSchema: undefined,
+ queryParamsSchema: undefined,
+ responseSchema: packageVersionRawMarkdownResponseDataSchema,
+ });
+}
+
+/**
+ * Fetches the raw CHANGELOG override of a version from the download endpoint.
+ * Returns null when the version has no CHANGELOG override. Plain-text
+ * endpoint, so this bypasses apiFetch's JSON handling.
+ */
+export async function fetchPackageVersionChangelogOverrideRaw(
+ props: ApiEndpointProps
+): Promise {
+ const { config, params } = props;
+ const path = `/api/cyberstorm/package/${params.namespace}/${params.package}/v/${params.version}/markdown/changelog/download/`;
+
+ const response = await fetch(new URL(path, config().apiHost), {
+ cache: "no-store",
+ });
+ if (response.status === 404) return null;
+ if (!response.ok) {
+ throw await ApiError.createFromResponse(response);
+ }
+ return await response.text();
+}
diff --git a/packages/thunderstore-api/src/get/packageVersionReadmeMarkdownRaw.ts b/packages/thunderstore-api/src/get/packageVersionReadmeMarkdownRaw.ts
new file mode 100644
index 000000000..e1744ad5d
--- /dev/null
+++ b/packages/thunderstore-api/src/get/packageVersionReadmeMarkdownRaw.ts
@@ -0,0 +1,50 @@
+import { apiFetch } from "../apiFetch";
+import { ApiError } from "../index";
+import type { ApiEndpointProps } from "../index";
+import type { PackageVersionMarkdownRequestParams } from "../schemas/requestSchemas";
+import { packageVersionRawMarkdownResponseDataSchema } from "../schemas/responseSchemas";
+import type { PackageVersionRawMarkdownResponseData } from "../schemas/responseSchemas";
+
+/**
+ * Fetches the resolved raw README of a version from the experimental API.
+ * Serves the override when one exists, the packaged file otherwise. Served
+ * from a five-minute server cache that edits do not bust.
+ */
+export function fetchPackageVersionReadmeMarkdownRaw(
+ props: ApiEndpointProps
+): Promise {
+ const { config, params } = props;
+ const path = `/api/experimental/package/${params.namespace}/${params.package}/${params.version}/readme/`;
+
+ return apiFetch({
+ args: {
+ config,
+ path,
+ request: { cache: "no-store" },
+ },
+ requestSchema: undefined,
+ queryParamsSchema: undefined,
+ responseSchema: packageVersionRawMarkdownResponseDataSchema,
+ });
+}
+
+/**
+ * Fetches the raw README override of a version from the download endpoint.
+ * Returns null when the version has no README override. Plain-text endpoint,
+ * so this bypasses apiFetch's JSON handling.
+ */
+export async function fetchPackageVersionReadmeOverrideRaw(
+ props: ApiEndpointProps
+): Promise {
+ const { config, params } = props;
+ const path = `/api/cyberstorm/package/${params.namespace}/${params.package}/v/${params.version}/markdown/readme/download/`;
+
+ const response = await fetch(new URL(path, config().apiHost), {
+ cache: "no-store",
+ });
+ if (response.status === 404) return null;
+ if (!response.ok) {
+ throw await ApiError.createFromResponse(response);
+ }
+ return await response.text();
+}
diff --git a/packages/thunderstore-api/src/index.ts b/packages/thunderstore-api/src/index.ts
index 98e002d29..2e5c41c4e 100644
--- a/packages/thunderstore-api/src/index.ts
+++ b/packages/thunderstore-api/src/index.ts
@@ -37,6 +37,8 @@ export * from "./get/packageListingDetails";
export * from "./get/packageReadme";
export * from "./get/packageSubmission";
export * from "./get/packageVersionDependencies";
+export * from "./get/packageVersionChangelogMarkdownRaw";
+export * from "./get/packageVersionReadmeMarkdownRaw";
export * from "./get/packageVersions";
export * from "./get/packageVersionDetails";
export * from "./get/packageWiki";
@@ -50,6 +52,8 @@ export * from "./patch/teamEditMember";
export * from "./post/frontend";
export * from "./post/package";
export * from "./post/packageListing";
+export * from "./post/packageVersionChangelog";
+export * from "./post/packageVersionReadme";
export * from "./post/packageWiki";
export * from "./post/submission";
export * from "./post/team";
diff --git a/packages/thunderstore-api/src/post/__tests__/packageVersionMarkdown.test.ts b/packages/thunderstore-api/src/post/__tests__/packageVersionMarkdown.test.ts
new file mode 100644
index 000000000..e156384da
--- /dev/null
+++ b/packages/thunderstore-api/src/post/__tests__/packageVersionMarkdown.test.ts
@@ -0,0 +1,54 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { RequestBodyParseError } from "../../index";
+import { postPackageVersionChangelog } from "../packageVersionChangelog";
+import { postPackageVersionReadme } from "../packageVersionReadme";
+
+const props = {
+ config: () => ({
+ apiHost: "https://api.example.invalid",
+ sessionId: "session",
+ }),
+ params: { namespace: "Team", package: "Mod", version: "1.0.0" },
+ queryParams: {},
+};
+
+const state = { is_edited: false, edited_at: null };
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe.each([
+ [
+ "readme",
+ (readme: string | null) =>
+ postPackageVersionReadme({ ...props, data: { readme } }),
+ ],
+ [
+ "changelog",
+ (changelog: string | null) =>
+ postPackageVersionChangelog({ ...props, data: { changelog } }),
+ ],
+] as const)("post %s override", (document, send) => {
+ it("rejects invalid values before sending a request", async () => {
+ const fetch = vi.fn();
+ vi.stubGlobal("fetch", fetch);
+ await expect(send(123 as unknown as string)).rejects.toBeInstanceOf(
+ RequestBodyParseError
+ );
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it.each([[""], [null]])("sends %j", async (value) => {
+ const response = new Response(
+ JSON.stringify({ readme: state, changelog: state }),
+ { status: 200 }
+ );
+ const fetch = vi.fn().mockResolvedValue(response);
+ vi.stubGlobal("fetch", fetch);
+ await send(value);
+ expect(fetch).toHaveBeenCalledWith(
+ expect.any(URL),
+ expect.objectContaining({ body: JSON.stringify({ [document]: value }) })
+ );
+ });
+});
diff --git a/packages/thunderstore-api/src/post/packageVersionChangelog.ts b/packages/thunderstore-api/src/post/packageVersionChangelog.ts
new file mode 100644
index 000000000..6c93de141
--- /dev/null
+++ b/packages/thunderstore-api/src/post/packageVersionChangelog.ts
@@ -0,0 +1,38 @@
+import { apiFetch } from "../apiFetch";
+import type { ApiEndpointProps } from "../index";
+import {
+ type PackageVersionChangelogRequestData,
+ type PackageVersionMarkdownRequestParams,
+ packageVersionChangelogRequestDataSchema,
+} from "../schemas/requestSchemas";
+import { packageVersionMarkdownResponseDataSchema } from "../schemas/responseSchemas";
+import type { PackageVersionMarkdownResponseData } from "../schemas/responseSchemas";
+
+/** Saves a version's CHANGELOG override, or discards it when changelog is null. */
+export function postPackageVersionChangelog(
+ props: ApiEndpointProps<
+ PackageVersionMarkdownRequestParams,
+ object,
+ PackageVersionChangelogRequestData
+ >
+): Promise {
+ const { config, params, data } = props;
+ const path = `/api/cyberstorm/package/${params.namespace}/${params.package}/v/${params.version}/markdown/`;
+
+ return apiFetch({
+ args: {
+ config,
+ path,
+ request: {
+ method: "POST",
+ cache: "no-store",
+ body: JSON.stringify(data),
+ },
+ bodyRaw: data,
+ useSession: true,
+ },
+ requestSchema: packageVersionChangelogRequestDataSchema,
+ queryParamsSchema: undefined,
+ responseSchema: packageVersionMarkdownResponseDataSchema,
+ });
+}
diff --git a/packages/thunderstore-api/src/post/packageVersionReadme.ts b/packages/thunderstore-api/src/post/packageVersionReadme.ts
new file mode 100644
index 000000000..4aab4bd7e
--- /dev/null
+++ b/packages/thunderstore-api/src/post/packageVersionReadme.ts
@@ -0,0 +1,38 @@
+import { apiFetch } from "../apiFetch";
+import type { ApiEndpointProps } from "../index";
+import {
+ type PackageVersionMarkdownRequestParams,
+ type PackageVersionReadmeRequestData,
+ packageVersionReadmeRequestDataSchema,
+} from "../schemas/requestSchemas";
+import { packageVersionMarkdownResponseDataSchema } from "../schemas/responseSchemas";
+import type { PackageVersionMarkdownResponseData } from "../schemas/responseSchemas";
+
+/** Saves a version's README override, or discards it when readme is null. */
+export function postPackageVersionReadme(
+ props: ApiEndpointProps<
+ PackageVersionMarkdownRequestParams,
+ object,
+ PackageVersionReadmeRequestData
+ >
+): Promise {
+ const { config, params, data } = props;
+ const path = `/api/cyberstorm/package/${params.namespace}/${params.package}/v/${params.version}/markdown/`;
+
+ return apiFetch({
+ args: {
+ config,
+ path,
+ request: {
+ method: "POST",
+ cache: "no-store",
+ body: JSON.stringify(data),
+ },
+ bodyRaw: data,
+ useSession: true,
+ },
+ requestSchema: packageVersionReadmeRequestDataSchema,
+ queryParamsSchema: undefined,
+ responseSchema: packageVersionMarkdownResponseDataSchema,
+ });
+}
diff --git a/packages/thunderstore-api/src/schemas/objectSchemas.ts b/packages/thunderstore-api/src/schemas/objectSchemas.ts
index a597056d1..2839b18ff 100644
--- a/packages/thunderstore-api/src/schemas/objectSchemas.ts
+++ b/packages/thunderstore-api/src/schemas/objectSchemas.ts
@@ -261,6 +261,7 @@ export const packageVersionSchema = z.object({
download_count: z.number().int(),
download_url: z.string(),
install_url: z.string(),
+ is_readme_edited: z.boolean().optional().default(false),
});
export const decompilationSchema = z.object({
diff --git a/packages/thunderstore-api/src/schemas/requestSchemas.ts b/packages/thunderstore-api/src/schemas/requestSchemas.ts
index d1b90570d..6d611edb5 100644
--- a/packages/thunderstore-api/src/schemas/requestSchemas.ts
+++ b/packages/thunderstore-api/src/schemas/requestSchemas.ts
@@ -392,6 +392,33 @@ export type PackageListingUpdateRequestData = z.infer<
typeof packageListingUpdateRequestDataSchema
>;
+// PackageVersionMarkdownRequest
+export const packageVersionMarkdownRequestParamsSchema = z.object({
+ namespace: z.string(),
+ package: z.string(),
+ version: z.string(),
+});
+
+export type PackageVersionMarkdownRequestParams = z.infer<
+ typeof packageVersionMarkdownRequestParamsSchema
+>;
+
+export const packageVersionReadmeRequestDataSchema = z.object({
+ readme: z.string().nullable(),
+});
+
+export type PackageVersionReadmeRequestData = z.infer<
+ typeof packageVersionReadmeRequestDataSchema
+>;
+
+export const packageVersionChangelogRequestDataSchema = z.object({
+ changelog: z.string().nullable(),
+});
+
+export type PackageVersionChangelogRequestData = z.infer<
+ typeof packageVersionChangelogRequestDataSchema
+>;
+
// PackageRateRequest
export const packageRateRequestParamsSchema = z.object({
namespace: z.string(),
diff --git a/packages/thunderstore-api/src/schemas/responseSchemas.ts b/packages/thunderstore-api/src/schemas/responseSchemas.ts
index 81c65dc35..2f07271d0 100644
--- a/packages/thunderstore-api/src/schemas/responseSchemas.ts
+++ b/packages/thunderstore-api/src/schemas/responseSchemas.ts
@@ -116,6 +116,8 @@ export type DynamicHTMLResponseData = z.infer<
// PackageChangelogResponse
export const packageChangelogResponseDataSchema = z.object({
html: z.string(),
+ is_edited: z.boolean().optional().default(false),
+ edited_at: z.string().datetime().nullable().optional().default(null),
});
export type PackageChangelogResponseData = z.infer<
@@ -177,12 +179,40 @@ export type PackageSourceResponseData = z.infer<
// PackageReadmeResponse
export const packageReadmeResponseDataSchema = z.object({
html: z.string(),
+ is_edited: z.boolean().optional().default(false),
+ edited_at: z.string().datetime().nullable().optional().default(null),
});
export type PackageReadmeResponseData = z.infer<
typeof packageReadmeResponseDataSchema
>;
+// PackageVersionMarkdownResponse (override write endpoint)
+export const packageVersionMarkdownStateSchema = z.object({
+ is_edited: z.boolean(),
+ edited_at: z.string().datetime().nullable(),
+});
+
+export const packageVersionMarkdownResponseDataSchema = z.object({
+ readme: packageVersionMarkdownStateSchema,
+ changelog: packageVersionMarkdownStateSchema,
+});
+
+export type PackageVersionMarkdownResponseData = z.infer<
+ typeof packageVersionMarkdownResponseDataSchema
+>;
+
+// PackageVersionRawMarkdownResponse (experimental raw markdown endpoints)
+export const packageVersionRawMarkdownResponseDataSchema = z.object({
+ markdown: z.string().nullable(),
+ is_edited: z.boolean().optional().default(false),
+ edited_at: z.string().datetime().nullable().optional().default(null),
+});
+
+export type PackageVersionRawMarkdownResponseData = z.infer<
+ typeof packageVersionRawMarkdownResponseDataSchema
+>;
+
// PackageVersionsResponse
export const packageVersionsResponseDataSchema = z.array(packageVersionSchema);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 926be3b23..82426cdb3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -215,6 +215,9 @@ importers:
'@types/semver':
specifier: 7.7.1
version: 7.7.1
+ '@zip.js/zip.js':
+ specifier: 2.11.1
+ version: 2.11.1
buffer:
specifier: 6.0.3
version: 6.0.3
@@ -3723,6 +3726,10 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
+ '@zip.js/zip.js@2.11.1':
+ resolution: {integrity: sha512-x86tIhuszaDZ9ek8Tp9AkcSvyWffiTH8SiDNhsmtW+DoSKkTDnLkX3lHGG39whb6gMWo6q+cU6DYTF/2htjwuQ==}
+ engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'}
+
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -4227,6 +4234,7 @@ packages:
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+ deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
css-color-keywords@1.0.0:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
@@ -10590,6 +10598,8 @@ snapshots:
'@xtuc/long@4.2.2': {}
+ '@zip.js/zip.js@2.11.1': {}
+
accepts@1.3.8:
dependencies:
mime-types: 2.1.35