Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions builder/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ class ExperimentalApiImpl extends ThunderstoreApi {
packageListingId: string;
data: {
categories: string[];
readme?: string;
changelog?: string;
};
}) => {
const response = await this.post(
Expand Down
49 changes: 48 additions & 1 deletion builder/src/components/PackageManagement/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import React, { CSSProperties } from "react";
import { useWatch } from "react-hook-form";
import { useManagementContext } from "./Context";
import { PackageListingUpdateForm, usePackageListingUpdateForm } from "./hooks";
import {
PackageListingUpdateForm,
PackageListingUpdateFormValues,
usePackageListingUpdateForm,
} from "./hooks";
import { PackageStatus } from "./PackageStatus";
import { CategoriesSelect } from "./CategoriesSelect";
import { DeprecationForm } from "./Deprecation";
Expand Down Expand Up @@ -31,6 +36,24 @@ interface BodyProps {

const Body: React.FC<BodyProps> = (props) => {
const context = useManagementContext().props;
const readme = useWatch<PackageListingUpdateFormValues, "readme">({
control: props.form.control,
name: "readme",
});
const changelog = useWatch<PackageListingUpdateFormValues, "changelog">({
control: props.form.control,
name: "changelog",
});

const handleFile = async (e: React.ChangeEvent<HTMLInputElement>, field: "readme" | "changelog") => {
const file = e.target.files?.[0];
if (!file) {
return;
}

const content = await file.text();
props.form.setValue(field, { fileName: file.name, content: content });
};

return (
<div className="modal-body">
Expand All @@ -49,6 +72,30 @@ const Body: React.FC<BodyProps> = (props) => {
<CategoriesSelect form={props.form} />
</div>
)}
<div className="mt-3">
<h6>Update Readme</h6>
<label className="btn btn-primary btn-lg btn-block">
<input
type="file"
accept=".md"
style={{ display: "none" }}
onChange={(e) => handleFile(e, "readme")}
/>
Comment on lines +77 to +83
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

File upload controls are not keyboard-accessible.

Using display: "none" on the file inputs (Line 81 and Line 93) removes the focusable control; keyboard users may not be able to trigger uploads reliably.

Also applies to: 89-95

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@builder/src/components/PackageManagement/Modal.tsx` around lines 77 - 83, The
file input elements inside the PackageManagement Modal are using style={{
display: "none" }}, which makes them unfocusable and breaks keyboard
accessibility; update the inputs used for readme and other uploads (the <input>
elements that call handleFile in the Modal component) to remain in the DOM and
focusable while visually hidden (e.g. use a screen-reader/visually-hidden class
or style such as position: "absolute", width/height: "1px", opacity: 0,
overflow: "hidden", clip: "rect(0 0 0 0)") so the <label> can trigger them via
keyboard and screen readers; ensure the label still wraps the input or uses
htmlFor with matching id and that handleFile continues to be called onChange.

{readme?.fileName ?? "Choose File"}
</label>
</div>
<div className="mt-3">
<h6>Update Changelog</h6>
<label className="btn btn-primary btn-lg btn-block">
<input
type="file"
accept=".md"
style={{ display: "none" }}
onChange={(e) => handleFile(e, "changelog")}
/>
{changelog?.fileName ?? "Choose File"}
</label>
</div>
</form>
{props.form.error && (
<div className={"alert alert-danger mt-2 mb-0"}>
Expand Down
13 changes: 11 additions & 2 deletions builder/src/components/PackageManagement/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ import { ExperimentalApi, UpdatePackageListingResponse } from "../../api";
import * as Sentry from "@sentry/react";
import { useState } from "react";
import { Control } from "react-hook-form/dist/types";
import { UseFormSetValue } from "react-hook-form";

type Status = undefined | "SUBMITTING" | "SUCCESS" | "ERROR";
export type PackageListingUpdateFormValues = {
categories: { value: string; label: string }[];
readme?: { fileName: string; content: string };
changelog?: { fileName: string; content: string };
};

export type PackageListingUpdateForm = {
onSubmit: () => Promise<void>;
setValue: UseFormSetValue<PackageListingUpdateFormValues>;
control: Control<PackageListingUpdateFormValues>;
error?: string;
status: Status;
Expand All @@ -20,7 +24,7 @@ export const usePackageListingUpdateForm = (
packageListingId: string,
onSuccess: (result: UpdatePackageListingResponse) => void
): PackageListingUpdateForm => {
const { handleSubmit, control } = useForm<PackageListingUpdateFormValues>();
const { handleSubmit, control, setValue } = useForm<PackageListingUpdateFormValues>();
const [status, setStatus] = useState<Status>(undefined);
const [error, setError] = useState<string | undefined>(undefined);

Expand All @@ -31,7 +35,11 @@ export const usePackageListingUpdateForm = (
try {
const result = await ExperimentalApi.updatePackageListing({
packageListingId: packageListingId,
data: { categories: data.categories.map((x) => x.value) },
data: {
categories: data.categories.map((x) => x.value),
readme: data.readme?.content,
changelog: data.changelog?.content,
},
Comment on lines +38 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether `categories` is optional end-to-end and whether form state guarantees it.

set -euo pipefail

echo "1) Serializer requirements:"
rg -n -C3 'class PackageListingUpdateRequestSerializer|categories\s*=|readme\s*=|changelog\s*=' \
  django/thunderstore/community/api/experimental/serializers.py

echo
echo "2) Frontend API contract:"
rg -n -C3 'updatePackageListing|UpdatePackageListingResponse|categories\??|readme\??|changelog\??' \
  builder/src/api -g '*.ts'

echo
echo "3) Form defaults / category initialization:"
rg -n -C4 'useForm<PackageListingUpdateFormValues>|defaultValues|setValue\("categories"|name:\s*"categories"|canUpdateCategories' \
  builder/src/components/PackageManagement -g '*.ts' -g '*.tsx'

Repository: thunderstore-io/Thunderstore

Length of output: 7120


🏁 Script executed:

#!/bin/bash
# Check PackageListingUpdateFormValues type definition and full onSubmit logic

echo "1) PackageListingUpdateFormValues type definition:"
rg -n -C8 'type PackageListingUpdateFormValues|interface PackageListingUpdateFormValues' \
  builder/src/components/PackageManagement/

echo
echo "2) Full usePackageListingUpdateForm hook with context:"
head -100 builder/src/components/PackageManagement/hooks.ts | cat -n

echo
echo "3) CategoriesSelect component to confirm conditional rendering:"
rg -n -B5 -A10 'export.*CategoriesSelect|function CategoriesSelect' \
  builder/src/components/PackageManagement/

Repository: thunderstore-io/Thunderstore

Length of output: 5808


Guard categories mapping to prevent runtime crash when field is unregistered.

Line 39's data.categories.map(...) crashes when the categories field is not registered (e.g., when canUpdateCategories is false and CategoriesSelect is not rendered). This blocks updates for users with readme/changelog-only permissions. Either initialize categories with a default value even when unrenderable, or conditionally include it in the payload only when defined.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@builder/src/components/PackageManagement/hooks.ts` around lines 38 - 42, The
payload building in the update call uses data.categories.map(...) which will
throw if categories is undefined; update the logic in the function that
constructs data: either initialize categories to an empty array when undefined
(e.g., ensure data.categories ?? [] is used) or only include the categories
property in the payload when data.categories is defined; target the object
construction where data: { categories: data.categories.map(...), readme:
data.readme?.content, changelog: data.changelog?.content } is created (and
consider the related canUpdateCategories / CategoriesSelect rendering path) so
users without the categories field won't crash updates.

});
onSuccess(result);
setStatus("SUCCESS");
Expand All @@ -44,6 +52,7 @@ export const usePackageListingUpdateForm = (

return {
onSubmit,
setValue,
control,
error,
status,
Expand Down
33 changes: 33 additions & 0 deletions django/thunderstore/api/cyberstorm/tests/test_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,22 @@ def test_readme_api_view__prerenders_markup(api_client: APIClient) -> None:
assert actual["html"] == "<h1>Very <strong>strong</strong> header</h1>\n"


@pytest.mark.django_db
def test_readme_api_view__uses_override_when_present(api_client: APIClient) -> None:
v = PackageVersionFactory(
readme="Original",
readme_override="Override",
)

response = api_client.get(
f"/api/cyberstorm/package/{v.package.namespace}/{v.package.name}/latest/readme/",
)
actual = response.json()

# Simple markdown paragraph
assert actual["html"] == "<p>Override</p>\n"


@pytest.mark.django_db
@pytest.mark.parametrize(
("markdown", "markup"),
Expand All @@ -94,6 +110,23 @@ def test_changelog_api_view__prerenders_markup(
assert actual["html"] == markup


@pytest.mark.django_db
def test_changelog_api_view__uses_override_when_present(
api_client: APIClient,
) -> None:
v = PackageVersionFactory(
changelog="Original changelog",
changelog_override="Override changelog",
)

response = api_client.get(
f"/api/cyberstorm/package/{v.package.namespace}/{v.package.name}/latest/changelog/",
)
actual = response.json()

assert actual["html"] == "<p>Override changelog</p>\n"


@pytest.mark.django_db
def test_changelog_api_view__when_package_has_no_changelog__returns_404(
api_client: APIClient,
Expand Down
4 changes: 4 additions & 0 deletions django/thunderstore/api/cyberstorm/views/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def get_object(self):
version_number=self.kwargs.get("version_number"),
)

if package_version.readme_override is not None:
return {"html": render_markdown(package_version.readme_override)}
return {"html": render_markdown(package_version.readme)}


Expand All @@ -57,6 +59,8 @@ def get_object(self):
if package_version.changelog is None:
raise Http404

if package_version.changelog_override is not None:
return {"html": render_markdown(package_version.changelog_override)}
return {"html": render_markdown(package_version.changelog)}


Expand Down
10 changes: 10 additions & 0 deletions django/thunderstore/community/api/experimental/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ class PackageListingUpdateRequestSerializer(serializers.Serializer):
),
allow_empty=True,
)
readme = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
)
changelog = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
)


class PackageCategoryExperimentalSerializer(serializers.Serializer):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from rest_framework.test import APIClient

from conftest import TestUserTypes
from thunderstore.cache.enums import CacheBustCondition
from thunderstore.community.models import PackageCategory, PackageListing
from thunderstore.repository.models import TeamMember

Expand Down Expand Up @@ -73,3 +74,45 @@ def test_api_experimental_package_listing_update(
]
assert active_package_listing.categories.count() == 1
assert package_category in active_package_listing.categories.all()


@pytest.mark.django_db
def test_api_experimental_package_listing_update_overrides_readme_and_changelog(
api_client: APIClient,
active_package_listing: PackageListing,
team_owner: TeamMember,
mocker,
):
assert team_owner.team == active_package_listing.package.owner
api_client.force_authenticate(user=team_owner.user)

mocked_invalidate = mocker.patch(
"thunderstore.community.api.experimental.views.listing.invalidate_cache_on_commit_async"
)

readme_markdown = "Override readme"
changelog_markdown = "Override changelog"

response = api_client.post(
f"/api/experimental/package-listing/{active_package_listing.pk}/update/",
data=json.dumps(
{
"categories": [],
"readme": readme_markdown,
"changelog": changelog_markdown,
}
),
content_type="application/json",
)

assert response.status_code == 200

latest = active_package_listing.package.latest
latest.refresh_from_db()

assert latest.readme_override == readme_markdown
assert latest.changelog_override == changelog_markdown

# Once for readme, once for changelog
mocked_invalidate.assert_called_with(CacheBustCondition.any_package_updated)
assert mocked_invalidate.call_count == 2
14 changes: 14 additions & 0 deletions django/thunderstore/community/api/experimental/views/listing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from thunderstore.cache.tasks import invalidate_cache_on_commit_async
from thunderstore.cache.enums import CacheBustCondition

from thunderstore.api.cyberstorm.services.package_listing import (
approve_package_listing,
Expand Down Expand Up @@ -45,6 +47,18 @@ def post(self, request, *args, **kwargs):
listing=listing,
)

if "readme" in request_serializer.validated_data:
readme = request_serializer.validated_data["readme"]
listing.package.latest.readme_override = readme
listing.package.latest.save()
invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated)

if "changelog" in request_serializer.validated_data:
changelog = request_serializer.validated_data["changelog"]
listing.package.latest.changelog_override = changelog
listing.package.latest.save()
invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated)
Comment on lines +50 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard nullable latest and persist overrides in one write.

listing.package.latest can be None, which would 500 here. Also, separate saves create avoidable partial-write behavior and duplicate cache-bust tasks.

Proposed fix
-        if "readme" in request_serializer.validated_data:
-            readme = request_serializer.validated_data["readme"]
-            listing.package.latest.readme_override = readme
-            listing.package.latest.save()
-            invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated)
-
-        if "changelog" in request_serializer.validated_data:
-            changelog = request_serializer.validated_data["changelog"]
-            listing.package.latest.changelog_override = changelog
-            listing.package.latest.save()
-            invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated)
+        latest = listing.package.latest
+        if latest is None:
+            raise serializers.ValidationError("Package has no latest version")
+
+        update_fields = []
+        if "readme" in request_serializer.validated_data:
+            latest.readme_override = request_serializer.validated_data["readme"]
+            update_fields.append("readme_override")
+        if "changelog" in request_serializer.validated_data:
+            latest.changelog_override = request_serializer.validated_data["changelog"]
+            update_fields.append("changelog_override")
+
+        if update_fields:
+            latest.save(update_fields=update_fields)
+            invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if "readme" in request_serializer.validated_data:
readme = request_serializer.validated_data["readme"]
listing.package.latest.readme_override = readme
listing.package.latest.save()
invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated)
if "changelog" in request_serializer.validated_data:
changelog = request_serializer.validated_data["changelog"]
listing.package.latest.changelog_override = changelog
listing.package.latest.save()
invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated)
latest = listing.package.latest
if latest is None:
raise serializers.ValidationError("Package has no latest version")
update_fields = []
if "readme" in request_serializer.validated_data:
latest.readme_override = request_serializer.validated_data["readme"]
update_fields.append("readme_override")
if "changelog" in request_serializer.validated_data:
latest.changelog_override = request_serializer.validated_data["changelog"]
update_fields.append("changelog_override")
if update_fields:
latest.save(update_fields=update_fields)
invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@django/thunderstore/community/api/experimental/views/listing.py` around lines
50 - 60, Guard against a null latest on the listing and write both overrides in
one save: check that listing.package.latest is not None (raise a ValidationError
on request_serializer or return a 400) before touching it, then set
latest.readme_override and latest.changelog_override from
request_serializer.validated_data (only when present) and call latest.save()
once, followed by a single
invalidate_cache_on_commit_async(CacheBustCondition.any_package_updated); update
the code paths that currently call listing.package.latest.save() twice to use
this single-save, single-cache-bust flow (symbols: listing, request_serializer,
listing.package.latest, invalidate_cache_on_commit_async,
CacheBustCondition.any_package_updated).


serializer = self.serializer_class(instance=listing)
return Response(serializer.data, status=status.HTTP_200_OK)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ def test_api_experimental_package_version_changelog(
assert result["markdown"] == changelog


@pytest.mark.django_db
def test_api_experimental_package_version_changelog_uses_override(
api_client: APIClient,
package_version: PackageVersion,
) -> None:
package_version.changelog = "Base changelog"
package_version.changelog_override = "Override changelog"
package_version.save()

response = api_client.get(
f"/api/experimental/package/"
f"{package_version.package.owner.name}/"
f"{package_version.package.name}/"
f"{package_version.version_number}/"
"changelog/"
)
assert response.status_code == 200
result = response.json()
assert result["markdown"] == "Override changelog"


@pytest.mark.django_db
@pytest.mark.parametrize("readme", ("", "# Test readme"))
def test_api_experimental_package_version_readme(
Expand All @@ -42,3 +63,24 @@ def test_api_experimental_package_version_readme(
assert response.status_code == 200
result = response.json()
assert result["markdown"] == readme


@pytest.mark.django_db
def test_api_experimental_package_version_readme_uses_override(
api_client: APIClient,
package_version: PackageVersion,
) -> None:
package_version.readme = "Base readme"
package_version.readme_override = "Override readme"
package_version.save()

response = api_client.get(
f"/api/experimental/package/"
f"{package_version.package.owner.name}/"
f"{package_version.package.name}/"
f"{package_version.version_number}/"
"readme/"
)
assert response.status_code == 200
result = response.json()
assert result["markdown"] == "Override readme"
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ def get(self, *args, **kwargs):

def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer({"markdown": instance.changelog})
if instance.changelog_override is not None:
serializer = self.get_serializer({"markdown": instance.changelog_override})
else:
serializer = self.get_serializer({"markdown": instance.changelog})
Comment on lines +88 to +91
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty string overrides will be used instead of falling back to the original changelog. The serializer allows allow_blank=True, so an empty string "" can be set as an override. The check if instance.changelog_override is not None: evaluates to True for empty strings, causing an empty changelog to be returned instead of the original.

Fix: Check for both None and empty string:

if instance.changelog_override:
    serializer = self.get_serializer({"markdown": instance.changelog_override})
else:
    serializer = self.get_serializer({"markdown": instance.changelog})
Suggested change
if instance.changelog_override is not None:
serializer = self.get_serializer({"markdown": instance.changelog_override})
else:
serializer = self.get_serializer({"markdown": instance.changelog})
if instance.changelog_override:
serializer = self.get_serializer({"markdown": instance.changelog_override})
else:
serializer = self.get_serializer({"markdown": instance.changelog})

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

return Response(serializer.data)


Expand All @@ -105,5 +108,8 @@ def get(self, *args, **kwargs):

def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer({"markdown": instance.readme})
if instance.readme_override is not None:
serializer = self.get_serializer({"markdown": instance.readme_override})
else:
serializer = self.get_serializer({"markdown": instance.readme})
return Response(serializer.data)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.1.7 on 2026-03-13 20:11

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('repository', '0064_add_namespaces_for_existing_teams'),
]

operations = [
migrations.AddField(
model_name='packageversion',
name='changelog_override',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='packageversion',
name='readme_override',
field=models.TextField(blank=True, null=True),
),
]
Loading