Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions src/app/src/components/content/ContentEditorForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,11 @@ async function setJSON(document: DatabasePageItem) {

switch (document.extension) {
case ContentFileExtension.JSON:
contentJSON.value = JSON.parse(generatedContent)
contentJSON.value = generatedContent ? JSON.parse(generatedContent) : {}
break
case ContentFileExtension.YAML:
case ContentFileExtension.YML:
contentJSON.value = yamlToJson(generatedContent)!
contentJSON.value = yamlToJson(generatedContent) ?? {}
break
}
}
Expand Down
18 changes: 16 additions & 2 deletions src/app/src/components/shared/item/ItemCardForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
ExtensionConfig,
CreateFolderParams,
} from '../../../types'
import { StudioItemActionId } from '../../../types'
import { ContentFileExtension, StudioItemActionId } from '../../../types'
import { joinURL, withLeadingSlash, withoutLeadingSlash } from 'ufo'
import { useStudio } from '../../../composables/useStudio'
import { parseName, getFileExtension, CONTENT_EXTENSIONS, MEDIA_EXTENSIONS } from '../../../utils/file'
Expand Down Expand Up @@ -140,6 +140,20 @@ const routePath = computed(() => {
return withLeadingSlash(joinURL(props.parentItem.routePath!, parseName(routePath).name))
})

function getInitialContent(extension: string, title: string): string {
// TODO: improve initial content based on collection schema
switch (extension) {
case ContentFileExtension.JSON:
return JSON.stringify({}, null, 2)
case ContentFileExtension.YAML:
case ContentFileExtension.YML:
return ''
case ContentFileExtension.Markdown:
default:
return `# ${title} file`
}
}

const displayInfo = computed(() => {
if (isDirectory.value) {
const itemCount = props.renamedItem?.children?.length || 0
Expand Down Expand Up @@ -211,7 +225,7 @@ async function onSubmit() {
case StudioItemActionId.CreateDocument:
params = {
fsPath: newFsPath,
content: `# ${upperFirst(state.name)} file`,
content: getInitialContent(state.extension!, upperFirst(state.name)),
Copy link
Contributor

Choose a reason for hiding this comment

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

The code uses a non-null assertion on state.extension when calling getInitialContent(), but the extension can be undefined when a default extension is not provided (e.g., in MediaCardForm), causing a type mismatch at runtime.

View Details
📝 Patch Details
diff --git a/src/app/src/components/shared/item/ItemCardForm.vue b/src/app/src/components/shared/item/ItemCardForm.vue
index 9a98d87..16af581 100644
--- a/src/app/src/components/shared/item/ItemCardForm.vue
+++ b/src/app/src/components/shared/item/ItemCardForm.vue
@@ -60,46 +60,61 @@ const fullName = computed(() => {
   return isDirectory.value ? prefixedName : `${prefixedName}.${state.extension}`
 })
 
-const schema = computed(() => z.object({
-  name: z.string()
-    .min(1, t('studio.validation.nameEmpty'))
-    .refine((name: string) => !name.endsWith('.'), t('studio.validation.nameEndsWithDot'))
-    .refine((name: string) => !name.startsWith('/'), t('studio.validation.nameStartsWithSlash')),
-  extension: z.enum([...CONTENT_EXTENSIONS, ...MEDIA_EXTENSIONS] as [string, ...string[]]).nullish(),
-  prefix: z.preprocess(
-    val => val === '' ? null : val,
-    z.string()
-      .regex(/^\d+$/, t('studio.validation.prefixDigitsOnly'))
+const schema = computed(() => {
+  // For file creation/renaming, extension is required; for folders, it's not applicable
+  const extensionSchema = isDirectory.value
+    ? z.enum([...CONTENT_EXTENSIONS, ...MEDIA_EXTENSIONS] as [string, ...string[]]).nullish()
+    : z.union([
+        z.enum([...CONTENT_EXTENSIONS, ...MEDIA_EXTENSIONS] as [string, ...string[]]),
+        z.null(),
+        z.undefined(),
+      ])
       .refine(
-        (prefix: string | null | undefined) => {
-          if (prefix === null || prefix === undefined) {
-            return true
-          }
+        (ext: string | null | undefined) => ext !== null && ext !== undefined,
+        { message: t('studio.validation.extensionRequired') },
+      )
 
-          const num = Number(prefix)
+  return z.object({
+    name: z.string()
+      .min(1, t('studio.validation.nameEmpty'))
+      .refine((name: string) => !name.endsWith('.'), t('studio.validation.nameEndsWithDot'))
+      .refine((name: string) => !name.startsWith('/'), t('studio.validation.nameStartsWithSlash')),
+    extension: extensionSchema,
+    prefix: z.preprocess(
+      val => val === '' ? null : val,
+      z.string()
+        .regex(/^\d+$/, t('studio.validation.prefixDigitsOnly'))
+        .refine(
+          (prefix: string | null | undefined) => {
+            if (prefix === null || prefix === undefined) {
+              return true
+            }
+
+            const num = Number(prefix)
+
+            return Number.isInteger(num) && num >= 0
+          },
+          t('studio.validation.prefixNonNegativeInteger'),
+        )
+        .nullish(),
+    ),
+  }).refine(() => {
+    const siblings = props.parentItem.children?.filter(child => !child.hide) || []
+
+    const isDuplicate = siblings.some((sibling) => {
+      const siblingBaseName = sibling.fsPath.split('/').pop()
+      if (props.renamedItem && sibling.fsPath === props.renamedItem.fsPath) {
+        return false
+      }
+      return siblingBaseName === fullName.value
+    })
 
-          return Number.isInteger(num) && num >= 0
-        },
-        t('studio.validation.prefixNonNegativeInteger'),
-      )
-      .nullish(),
-  ),
-}).refine(() => {
-  const siblings = props.parentItem.children?.filter(child => !child.hide) || []
-
-  const isDuplicate = siblings.some((sibling) => {
-    const siblingBaseName = sibling.fsPath.split('/').pop()
-    if (props.renamedItem && sibling.fsPath === props.renamedItem.fsPath) {
-      return false
-    }
-    return siblingBaseName === fullName.value
+    return !isDuplicate
+  }, {
+    message: t('studio.validation.nameExists'),
+    path: ['name'],
   })
-
-  return !isDuplicate
-}, {
-  message: t('studio.validation.nameExists'),
-  path: ['name'],
-}))
+})
 
 type SchemaType = {
   name: string
@@ -225,7 +240,7 @@ async function onSubmit() {
     case StudioItemActionId.CreateDocument:
       params = {
         fsPath: newFsPath,
-        content: getInitialContent(state.extension!, upperFirst(state.name)),
+        content: getInitialContent(state.extension as string, upperFirst(state.name)),
       }
       break
     case StudioItemActionId.RenameItem:

Analysis

Extension validation missing in ItemCardForm allows undefined extension for file creation

What fails: ItemCardForm accepts undefined extension when creating media files, allowing invalid file creation with extensions like "myvideo.undefined" and passing undefined to getInitialContent() which expects a string parameter.

How to reproduce:

  1. In MediaCardForm (which uses ItemCardForm with config: { allowed: MEDIA_EXTENSIONS, editable: false } - no default)
  2. Attempt to create a new document (not rename, where renamedItem = null)
  3. The extension field is disabled (editable: false), so user cannot select an extension
  4. originalExtension computes to props.config.default which is undefined
  5. The Zod schema allows z.enum(...).nullish() - validation passes with undefined
  6. Submit button is enabled because validation passes
  7. On submit with CreateDocument action, calls getInitialContent(state.extension!, ...) with undefined

Result:

  • File created with name like "myvideo.undefined"
  • getInitialContent receives undefined instead of string
  • Type assertion masks the issue but doesn't prevent runtime problem

Expected:

  • Extension should be required when creating files
  • Form validation should prevent submission without a valid extension
  • Follows the pattern in ContentCardForm which provides a default

Fix applied: Modified src/app/src/components/shared/item/ItemCardForm.vue to conditionally require extension:

  • For file creation/renaming (!isDirectory): extension must be a valid string value
  • For folder creation (isDirectory): extension can be null/undefined
  • Validation now prevents form submission without selecting a valid extension

}
break
case StudioItemActionId.RenameItem:
Expand Down
Loading