fix(themes): enforce strict semver versions

Require SemVer 2.0.0 when saving, validating, or publishing a theme, and validate again at the workshop upload boundary. This rejects partial versions, v-prefixed versions, leading-zero components, and arbitrary strings while preserving valid prerelease and build metadata.
This commit is contained in:
HorizonCode committed 2026-11-04 00:00:07 +01:00
1 parent 07cbfa39a6
commit 51f5fa3e46
3 files changed
+32 -5

No files matched your search

+9 -1
View File
@@ -1,4 +1,5 @@
import { config } from './config'; import { config } from './config';
import { normalizeThemeVersion } from './themeV2';
import { import {
reloadThemes, reloadThemes,
themesDirectory, themesDirectory,
@@ -727,6 +728,13 @@ export async function publishThemeToWorkshop(options: {
throw new Error('You must be logged in to EZPPFarm to publish themes.'); throw new Error('You must be logged in to EZPPFarm to publish themes.');
} }
const version = normalizeThemeVersion(options.version ?? '1.0.0');
if (!version) {
throw new Error(
'Theme version must use semantic versioning, for example 1.2.3 or 1.2.3-beta.1.'
);
}
onProgress('Packaging theme archive...', 5); onProgress('Packaging theme archive...', 5);
const baseThemesDir = await themesDirectory(); const baseThemesDir = await themesDirectory();
@@ -771,7 +779,7 @@ export async function publishThemeToWorkshop(options: {
themeId: options.workshopId || options.themeId, themeId: options.workshopId || options.themeId,
name: options.name, name: options.name,
description: options.description || '', description: options.description || '',
version: options.version || '1.0.0', version,
apiVersion: options.apiVersion || '2.0.0', apiVersion: options.apiVersion || '2.0.0',
totalSize, totalSize,
totalChunks, totalChunks,
@@ -75,6 +75,7 @@
normalizeChrome, normalizeChrome,
normalizeThemeTags, normalizeThemeTags,
normalizeThemeV2, normalizeThemeV2,
normalizeThemeVersion,
themeAssetName, themeAssetName,
themeHasLaunchButton, themeHasLaunchButton,
type ThemeV2Asset, type ThemeV2Asset,
@@ -2923,6 +2924,12 @@
notifyError('Add an author name so people know who made the theme.'); notifyError('Add an author name so people know who made the theme.');
return false; return false;
} }
const version = normalizeThemeVersion(definition.version);
if (!version) {
notifyError('The version must use semantic versioning, for example 1.2.3 or 1.2.3-beta.1.');
return false;
}
if (definition.version !== version) definition.version = version;
const folderName = savedFolder ?? toSafeName(definition.name); const folderName = savedFolder ?? toSafeName(definition.name);
if (!folderName.replace(/_/g, '')) { if (!folderName.replace(/_/g, '')) {
notifyError('The theme name needs at least one letter or number.'); notifyError('The theme name needs at least one letter or number.');
@@ -3019,8 +3026,10 @@
if (!definition.name.trim()) error('The theme needs a name.'); if (!definition.name.trim()) error('The theme needs a name.');
if (!definition.author.trim()) error('The theme needs an author.'); if (!definition.author.trim()) error('The theme needs an author.');
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(definition.version.trim())) { if (!normalizeThemeVersion(definition.version)) {
warning('The version should use semantic versioning, for example 1.0.0.'); error(
'The version must use semantic versioning, for example 1.0.0 or 1.0.0-beta.1, with no "v" prefix.'
);
} }
if (!themeHasLaunchButton(definition)) { if (!themeHasLaunchButton(definition)) {
error( error(
@@ -3346,7 +3355,7 @@
async function checkPublishVersion(): Promise< async function checkPublishVersion(): Promise<
{ ok: true; version: string } | { ok: false; error: string } { ok: true; version: string } | { ok: false; error: string }
> { > {
const proposed = semver.valid(definition.version.trim()); const proposed = normalizeThemeVersion(definition.version);
if (!proposed) { if (!proposed) {
return { return {
ok: false, ok: false,
@@ -3369,7 +3378,7 @@
}; };
} }
const validRemote = semver.valid(remoteVersion); const validRemote = normalizeThemeVersion(remoteVersion);
if (!validRemote) { if (!validRemote) {
return { return {
ok: false, ok: false,
+10
View File
@@ -748,6 +748,16 @@ export const normalizeThemeTags = (raw: unknown): string[] => {
return tags; return tags;
}; };
const THEME_VERSION_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
/** Accept only strict SemVer 2.0.0, preserving prerelease and build metadata. */
export const normalizeThemeVersion = (raw: unknown): string | undefined => {
if (typeof raw !== 'string') return undefined;
const version = raw.trim();
return version.length > 0 && THEME_VERSION_PATTERN.test(version) ? version : undefined;
};
const WORKSHOP_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; const WORKSHOP_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
/** Keep a server-assigned workshop ID while preserving legacy slug IDs. */ /** Keep a server-assigned workshop ID while preserving legacy slug IDs. */